mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Run code mode exclusively through the standalone host (#36217)
## What changed - Move the V8 implementation into a dedicated `codex-code-mode-runtime` crate used by `codex-code-mode-host`, removing the embedded runtime fallback from the Codex process. - Resolve the host executable from the active installation layout and check its availability before selecting tools. - Fall back to direct tools with a one-time warning when optional code mode is unavailable. Keep `code_mode_only` and `disable_in_process_fallback` configurations fail-closed. ## Testing - Cover host discovery for standalone and package layouts, including missing hosts and symlinks. - Verify direct-tool fallback, one-time warnings, and fail-closed code-mode-only behavior. GitOrigin-RevId: 5aa3c6f1db148b2231fc24089a2ee0e2b00dbddb
This commit is contained in:
committed by
copyberry
parent
acd540f158
commit
97576b1794
@@ -9,26 +9,21 @@ doctest = false
|
||||
name = "codex_code_mode"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
sandbox = ["v8/v8_enable_sandbox"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-code-mode-protocol = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-websocket-client = { workspace = true }
|
||||
deno_core_icudata = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["rt"] }
|
||||
tracing = { workspace = true }
|
||||
v8 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::FutureExt;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use super::CellHost;
|
||||
use super::CellToolCall;
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum CallbackCompletion {
|
||||
DrainNotifications,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub(super) fn spawn_notification<H: CellHost>(
|
||||
tasks: &mut JoinSet<()>,
|
||||
host: Arc<H>,
|
||||
call_id: String,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) {
|
||||
tasks.spawn(async move {
|
||||
let callback =
|
||||
AssertUnwindSafe(async move { host.notify(call_id, text, cancellation_token).await })
|
||||
.catch_unwind()
|
||||
.await;
|
||||
match callback {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => warn!("failed to deliver code mode notification: {err}"),
|
||||
Err(_) => report_task_failure(
|
||||
task_failure_handler.as_ref(),
|
||||
"code mode notification task panicked".to_string(),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn spawn_tool<H: CellHost>(
|
||||
tasks: &mut JoinSet<()>,
|
||||
host: Arc<H>,
|
||||
invocation: CellToolCall,
|
||||
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
cancellation_token: CancellationToken,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) {
|
||||
tasks.spawn(async move {
|
||||
let id = invocation.id.clone();
|
||||
let callback =
|
||||
AssertUnwindSafe(async move { host.invoke_tool(invocation, cancellation_token).await })
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let (command, failure_reason) = match callback {
|
||||
Ok(Ok(result)) => (RuntimeCommand::ToolResponse { id, result }, None),
|
||||
Ok(Err(error_text)) => (RuntimeCommand::ToolError { id, error_text }, None),
|
||||
Err(_) => {
|
||||
let failure_reason = "code mode tool task panicked".to_string();
|
||||
(
|
||||
RuntimeCommand::ToolError {
|
||||
id,
|
||||
error_text: failure_reason.clone(),
|
||||
},
|
||||
Some(failure_reason),
|
||||
)
|
||||
}
|
||||
};
|
||||
let _ = runtime_tx.send(command);
|
||||
if let Some(failure_reason) = failure_reason {
|
||||
report_task_failure(task_failure_handler.as_ref(), failure_reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) async fn finish_callbacks(
|
||||
cancellation_token: &CancellationToken,
|
||||
notification_tasks: &mut JoinSet<()>,
|
||||
tool_tasks: &mut JoinSet<()>,
|
||||
completion: CallbackCompletion,
|
||||
task_failure_handler: Option<&TaskFailureHandler>,
|
||||
) {
|
||||
if matches!(completion, CallbackCompletion::Cancel) {
|
||||
cancellation_token.cancel();
|
||||
}
|
||||
drain_tasks(notification_tasks, "notification", task_failure_handler).await;
|
||||
cancellation_token.cancel();
|
||||
drain_tasks(tool_tasks, "tool", task_failure_handler).await;
|
||||
}
|
||||
|
||||
pub(super) fn report_task_result(
|
||||
task_result: Option<Result<(), tokio::task::JoinError>>,
|
||||
description: &str,
|
||||
task_failure_handler: Option<&TaskFailureHandler>,
|
||||
) {
|
||||
if let Some(Err(err)) = task_result
|
||||
&& !err.is_cancelled()
|
||||
{
|
||||
report_task_failure(
|
||||
task_failure_handler,
|
||||
format!("code mode {description} task failed: {err}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn report_task_failure(task_failure_handler: Option<&TaskFailureHandler>, failure_reason: String) {
|
||||
warn!("{failure_reason}");
|
||||
if let Some(task_failure_handler) = task_failure_handler {
|
||||
task_failure_handler(failure_reason);
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_tasks(
|
||||
tasks: &mut JoinSet<()>,
|
||||
description: &str,
|
||||
task_failure_handler: Option<&TaskFailureHandler>,
|
||||
) {
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
report_task_result(Some(result), description, task_failure_handler);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "callbacks_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,132 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::cell_actor::CellState;
|
||||
use crate::cell_actor::CompletionCommit;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
use crate::session_runtime::CellEvent;
|
||||
use crate::session_runtime::ToolKind;
|
||||
use crate::session_runtime::ToolName;
|
||||
|
||||
struct PanickingCallbackHost;
|
||||
|
||||
impl CellHost for PanickingCallbackHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
_invocation: CellToolCall,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
panic!("tool callback panic probe");
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
_call_id: String,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
panic!("notification callback panic probe");
|
||||
}
|
||||
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, JsonValue>,
|
||||
_event: CellEvent,
|
||||
_pending_initial_yield_items: Option<Vec<crate::session_runtime::OutputItem>>,
|
||||
_cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
panic!("unexpected completion commit");
|
||||
}
|
||||
|
||||
async fn closed(&self) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_callback_panic_rejects_the_js_promise_and_reports_failure() {
|
||||
let mut tasks = JoinSet::new();
|
||||
let (runtime_tx, runtime_rx) = std_mpsc::channel();
|
||||
let (failure_tx, mut failure_rx) = mpsc::unbounded_channel();
|
||||
spawn_tool(
|
||||
&mut tasks,
|
||||
Arc::new(PanickingCallbackHost),
|
||||
CellToolCall {
|
||||
id: "tool-1".to_string(),
|
||||
name: ToolName {
|
||||
name: "panic".to_string(),
|
||||
namespace: None,
|
||||
},
|
||||
kind: ToolKind::Function,
|
||||
input: None,
|
||||
},
|
||||
runtime_tx,
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
);
|
||||
|
||||
tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("tool callback task")
|
||||
.expect("tool callback wrapper");
|
||||
let command = runtime_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("tool error command");
|
||||
let RuntimeCommand::ToolError { id, error_text } = command else {
|
||||
panic!("expected a tool error command");
|
||||
};
|
||||
assert_eq!(id, "tool-1");
|
||||
assert_eq!(error_text, "code mode tool task panicked");
|
||||
assert_eq!(failure_rx.recv().await, Some(error_text));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_callback_panic_reports_failure() {
|
||||
let mut tasks = JoinSet::new();
|
||||
let (failure_tx, mut failure_rx) = mpsc::unbounded_channel();
|
||||
spawn_notification(
|
||||
&mut tasks,
|
||||
Arc::new(PanickingCallbackHost),
|
||||
"notify-1".to_string(),
|
||||
"hello".to_string(),
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
);
|
||||
|
||||
tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("notification callback task")
|
||||
.expect("notification callback wrapper");
|
||||
let failure_reason = failure_rx.recv().await.expect("notification failure");
|
||||
assert_eq!(failure_reason, "code mode notification task panicked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_wrapper_join_error_reports_failure() {
|
||||
let task_result = tokio::spawn(async {
|
||||
panic!("callback wrapper panic probe");
|
||||
})
|
||||
.await;
|
||||
let (failure_tx, mut failure_rx) = mpsc::unbounded_channel();
|
||||
let task_failure_handler: TaskFailureHandler = Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
});
|
||||
|
||||
report_task_result(Some(task_result), "tool", Some(&task_failure_handler));
|
||||
|
||||
let failure_reason = failure_rx.recv().await.expect("wrapper failure");
|
||||
assert!(failure_reason.contains("code mode tool task failed"));
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 {
|
||||
ExecuteRequest {
|
||||
tool_call_id: request.tool_call_id,
|
||||
enabled_tools: request
|
||||
.enabled_tools
|
||||
.into_iter()
|
||||
.map(|definition| ToolDefinition {
|
||||
name: definition.name,
|
||||
tool_name: ToolName {
|
||||
name: definition.tool_name.name,
|
||||
namespace: definition.tool_name.namespace,
|
||||
},
|
||||
description: definition.description,
|
||||
kind: match definition.kind {
|
||||
CellToolKind::Function => CodeModeToolKind::Function,
|
||||
CellToolKind::Freeform => CodeModeToolKind::Freeform,
|
||||
},
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
})
|
||||
.collect(),
|
||||
source: request.source,
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cell_tool_kind(kind: CodeModeToolKind) -> CellToolKind {
|
||||
match kind {
|
||||
CodeModeToolKind::Function => CellToolKind::Function,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
mod callbacks;
|
||||
mod conversions;
|
||||
mod types;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use self::callbacks::CallbackCompletion;
|
||||
use self::callbacks::finish_callbacks;
|
||||
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;
|
||||
pub(crate) use self::types::CellEventFuture;
|
||||
pub(crate) use self::types::CellHandle;
|
||||
pub(crate) use self::types::CellHost;
|
||||
pub(crate) use self::types::CellState;
|
||||
pub(crate) use self::types::CellToolCall;
|
||||
pub(crate) use self::types::CompletionCommit;
|
||||
use self::types::CompletionDelivery;
|
||||
use self::types::ObservationDelivery;
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::runtime::PendingRuntimeMode;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
use crate::runtime::RuntimeControlCommand;
|
||||
use crate::runtime::RuntimeEvent;
|
||||
use crate::runtime::spawn_runtime;
|
||||
use crate::session_runtime::CellEvent;
|
||||
use crate::session_runtime::CreateCellRequest as CellRequest;
|
||||
use crate::session_runtime::ObserveMode;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use crate::session_runtime::ToolName as CellToolName;
|
||||
|
||||
pub(crate) struct CellActor;
|
||||
|
||||
impl CellActor {
|
||||
pub(crate) fn prepare<H: CellHost>(
|
||||
request: CellRequest,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
host: Arc<H>,
|
||||
initial_observe_mode: ObserveMode,
|
||||
cell_state: Arc<CellState>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> Result<
|
||||
(
|
||||
CellHandle,
|
||||
CellEventFuture,
|
||||
impl Future<Output = ()> + Send + 'static,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
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(
|
||||
stored_values,
|
||||
runtime_request(request),
|
||||
event_tx,
|
||||
PendingRuntimeMode::PauseUntilResumed,
|
||||
task_failure_handler.clone(),
|
||||
)?;
|
||||
let handle = CellHandle::new(command_tx, Arc::clone(&cell_state));
|
||||
let task = run_cell(
|
||||
host,
|
||||
CellContext {
|
||||
runtime_tx,
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
},
|
||||
event_rx,
|
||||
command_rx,
|
||||
Observer {
|
||||
mode: initial_observe_mode,
|
||||
response_tx: initial_response_tx,
|
||||
},
|
||||
task_failure_handler,
|
||||
);
|
||||
let initial_response =
|
||||
Box::pin(async move { initial_response_rx.await.unwrap_or(Err(CellError::Closed)) });
|
||||
Ok((handle, initial_response, task))
|
||||
}
|
||||
}
|
||||
|
||||
struct CellContext {
|
||||
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
runtime_control_tx: std::sync::mpsc::Sender<RuntimeControlCommand>,
|
||||
runtime_terminate_handle: v8::IsolateHandle,
|
||||
cell_state: Arc<CellState>,
|
||||
}
|
||||
|
||||
struct Observer {
|
||||
mode: ObserveMode,
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
}
|
||||
|
||||
async fn run_cell<H: CellHost>(
|
||||
host: Arc<H>,
|
||||
context: CellContext,
|
||||
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
|
||||
command_rx: mpsc::UnboundedReceiver<CellCommand>,
|
||||
initial_observer: Observer,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) {
|
||||
let CellContext {
|
||||
runtime_tx,
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
} = 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 observer = Some(initial_observer);
|
||||
let mut termination = false;
|
||||
let mut runtime_closed = false;
|
||||
let mut runtime_paused = false;
|
||||
let mut runtime_failure_reported = false;
|
||||
let mut yield_timer: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||
let mut notification_tasks = JoinSet::new();
|
||||
let mut tool_tasks = JoinSet::new();
|
||||
let mut command_rx = Some(command_rx);
|
||||
loop {
|
||||
let yield_deadline_elapsed = yield_timer
|
||||
.as_ref()
|
||||
.is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now());
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation_token.cancelled(), if !termination => {
|
||||
termination = true;
|
||||
yield_timer = None;
|
||||
drop(command_rx.take());
|
||||
begin_termination(
|
||||
&runtime_tx,
|
||||
&runtime_control_tx,
|
||||
&runtime_terminate_handle,
|
||||
&cancellation_token,
|
||||
);
|
||||
if runtime_closed {
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::Cancel,
|
||||
task_failure_handler.as_ref(),
|
||||
).await;
|
||||
finish_termination(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
maybe_command = async {
|
||||
match command_rx.as_mut() {
|
||||
Some(command_rx) => command_rx.recv().await,
|
||||
None => std::future::pending::<Option<CellCommand>>().await,
|
||||
}
|
||||
} => {
|
||||
let Some(CellCommand::Observe { mode, response_tx }) = maybe_command else {
|
||||
cancellation_token.cancel();
|
||||
continue;
|
||||
};
|
||||
if response_tx.is_closed() {
|
||||
continue;
|
||||
}
|
||||
let response_tx = match cell_state.route_observation(mode, response_tx) {
|
||||
ObservationDelivery::Running(response_tx) => response_tx,
|
||||
ObservationDelivery::Delivered => break,
|
||||
ObservationDelivery::Buffered | ObservationDelivery::Closed => continue,
|
||||
};
|
||||
if observer
|
||||
.as_ref()
|
||||
.is_some_and(|observer| observer.response_tx.is_closed())
|
||||
{
|
||||
observer = None;
|
||||
yield_timer = None;
|
||||
}
|
||||
if observer.is_some() || termination {
|
||||
let _ = response_tx.send(Err(CellError::Busy));
|
||||
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:?}")
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
observer = Some(Observer { mode, response_tx });
|
||||
yield_timer = observer.as_ref().and_then(observer_timer);
|
||||
if runtime_paused && matches!(mode, ObserveMode::YieldAfter(_)) {
|
||||
pending_frontier_ready = false;
|
||||
pending_tool_call_ids.clear();
|
||||
}
|
||||
resume_for_observation(
|
||||
mode,
|
||||
&mut runtime_paused,
|
||||
&runtime_tx,
|
||||
&runtime_control_tx,
|
||||
);
|
||||
}
|
||||
_ = async {
|
||||
if let Some(yield_timer) = yield_timer.as_mut() {
|
||||
yield_timer.await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => {
|
||||
yield_timer = None;
|
||||
restore_undelivered_yield(
|
||||
send_observer_event(
|
||||
observer.take(),
|
||||
CellEvent::Yielded {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
),
|
||||
&mut content_items,
|
||||
);
|
||||
}
|
||||
maybe_event = async {
|
||||
if runtime_closed {
|
||||
std::future::pending::<Option<RuntimeEvent>>().await
|
||||
} else {
|
||||
event_rx.recv().await
|
||||
}
|
||||
}, if !yield_deadline_elapsed => {
|
||||
let Some(event) = maybe_event else {
|
||||
runtime_closed = true;
|
||||
if termination || cancellation_token.is_cancelled() {
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::Cancel,
|
||||
task_failure_handler.as_ref(),
|
||||
).await;
|
||||
finish_termination(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
if !runtime_failure_reported
|
||||
&& let Some(task_failure_handler) = &task_failure_handler
|
||||
{
|
||||
runtime_failure_reported = true;
|
||||
task_failure_handler(
|
||||
"code-mode V8 runtime thread ended unexpectedly".to_string(),
|
||||
);
|
||||
}
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::DrainNotifications,
|
||||
task_failure_handler.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let event = CellEvent::Completed {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
error_text: Some("exec runtime ended unexpectedly".to_string()),
|
||||
};
|
||||
let rejected_event = match host
|
||||
.commit_completion(
|
||||
HashMap::new(),
|
||||
event,
|
||||
/*pending_initial_yield_items*/ None,
|
||||
Arc::clone(&cell_state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
CompletionCommit::Committed => None,
|
||||
CompletionCommit::Rejected(event) => Some(event),
|
||||
};
|
||||
match cell_state.deliver_completion(
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
) {
|
||||
CompletionDelivery::Delivered => break,
|
||||
CompletionDelivery::Buffered => {}
|
||||
CompletionDelivery::Rejected(response_tx) => {
|
||||
finish_termination(
|
||||
&cell_state,
|
||||
response_tx,
|
||||
CellEvent::Terminated {
|
||||
content_items: rejected_completion_content(rejected_event),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match event {
|
||||
RuntimeEvent::Started => {
|
||||
yield_timer = observer.as_ref().and_then(observer_timer);
|
||||
}
|
||||
RuntimeEvent::Pending => {
|
||||
runtime_paused = true;
|
||||
if matches!(
|
||||
observer.as_ref().map(|observer| observer.mode),
|
||||
Some(ObserveMode::PendingFrontier)
|
||||
) {
|
||||
yield_timer = 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:?}")
|
||||
}
|
||||
}
|
||||
} 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::YieldRequested => {
|
||||
let yield_observer = matches!(
|
||||
observer.as_ref().map(|observer| observer.mode),
|
||||
Some(ObserveMode::YieldAfter(_))
|
||||
);
|
||||
if yield_observer {
|
||||
yield_timer = None;
|
||||
restore_undelivered_yield(
|
||||
send_observer_event(
|
||||
observer.take(),
|
||||
CellEvent::Yielded {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
),
|
||||
&mut content_items,
|
||||
);
|
||||
}
|
||||
}
|
||||
RuntimeEvent::Notify { call_id, text } => {
|
||||
spawn_notification(
|
||||
&mut notification_tasks,
|
||||
Arc::clone(&host),
|
||||
call_id,
|
||||
text,
|
||||
callback_cancellation_token.child_token(),
|
||||
task_failure_handler.clone(),
|
||||
);
|
||||
}
|
||||
RuntimeEvent::ToolCall { id, name, kind, input } => {
|
||||
pending_tool_call_ids.push(id.clone());
|
||||
spawn_tool(
|
||||
&mut tool_tasks,
|
||||
Arc::clone(&host),
|
||||
CellToolCall {
|
||||
id,
|
||||
name: CellToolName {
|
||||
name: name.name,
|
||||
namespace: name.namespace,
|
||||
},
|
||||
kind: cell_tool_kind(kind),
|
||||
input,
|
||||
},
|
||||
runtime_tx.clone(),
|
||||
callback_cancellation_token.child_token(),
|
||||
task_failure_handler.clone(),
|
||||
);
|
||||
}
|
||||
RuntimeEvent::Result { stored_value_writes, error_text } => {
|
||||
runtime_closed = true;
|
||||
yield_timer = None;
|
||||
if termination || cancellation_token.is_cancelled() {
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::Cancel,
|
||||
task_failure_handler.as_ref(),
|
||||
).await;
|
||||
finish_termination(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::DrainNotifications,
|
||||
task_failure_handler.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let event = CellEvent::Completed {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
error_text,
|
||||
};
|
||||
let rejected_event = match host
|
||||
.commit_completion(
|
||||
stored_value_writes,
|
||||
event,
|
||||
/*pending_initial_yield_items*/ None,
|
||||
Arc::clone(&cell_state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
CompletionCommit::Committed => None,
|
||||
CompletionCommit::Rejected(event) => Some(event),
|
||||
};
|
||||
match cell_state.deliver_completion(
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
) {
|
||||
CompletionDelivery::Delivered => break,
|
||||
CompletionDelivery::Buffered => {}
|
||||
CompletionDelivery::Rejected(response_tx) => {
|
||||
finish_termination(
|
||||
&cell_state,
|
||||
response_tx,
|
||||
CellEvent::Terminated {
|
||||
content_items: rejected_completion_content(rejected_event),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
RuntimeEvent::ThreadPanicked => {
|
||||
runtime_failure_reported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => {
|
||||
report_task_result(
|
||||
task_result,
|
||||
"notification",
|
||||
task_failure_handler.as_ref(),
|
||||
);
|
||||
}
|
||||
task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => {
|
||||
report_task_result(task_result, "tool", task_failure_handler.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject requests that arrive while asynchronous terminal cleanup runs.
|
||||
cell_state.tombstone();
|
||||
drop(command_rx.take());
|
||||
begin_termination(
|
||||
&runtime_tx,
|
||||
&runtime_control_tx,
|
||||
&runtime_terminate_handle,
|
||||
&cancellation_token,
|
||||
);
|
||||
finish_callbacks(
|
||||
&callback_cancellation_token,
|
||||
&mut notification_tasks,
|
||||
&mut tool_tasks,
|
||||
CallbackCompletion::Cancel,
|
||||
task_failure_handler.as_ref(),
|
||||
)
|
||||
.await;
|
||||
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)
|
||||
}
|
||||
|
||||
fn send_cell_event(
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
event: CellEvent,
|
||||
) -> Result<(), CellEvent> {
|
||||
match response_tx.send(Ok(event)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(Ok(event)) => Err(event),
|
||||
Err(Err(error)) => panic!("cell event delivery returned an actor error: {error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
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 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 finish_termination(
|
||||
cell_state: &CellState,
|
||||
observer_tx: Option<oneshot::Sender<Result<CellEvent, CellError>>>,
|
||||
event: CellEvent,
|
||||
) {
|
||||
if let Some(event) = cell_state.finish_termination(event)
|
||||
&& let Some(observer_tx) = observer_tx
|
||||
{
|
||||
let _ = observer_tx.send(Ok(event));
|
||||
}
|
||||
}
|
||||
|
||||
fn observer_timer(observer: &Observer) -> Option<std::pin::Pin<Box<tokio::time::Sleep>>> {
|
||||
match observer.mode {
|
||||
ObserveMode::YieldAfter(duration) => Some(Box::pin(tokio::time::sleep(duration))),
|
||||
ObserveMode::PendingFrontier => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_for_observation(
|
||||
mode: ObserveMode,
|
||||
runtime_paused: &mut bool,
|
||||
runtime_tx: &std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
|
||||
) {
|
||||
if *runtime_paused {
|
||||
let control = match mode {
|
||||
ObserveMode::YieldAfter(_) => RuntimeControlCommand::Continue,
|
||||
ObserveMode::PendingFrontier => RuntimeControlCommand::Resume,
|
||||
};
|
||||
let _ = runtime_control_tx.send(control);
|
||||
*runtime_paused = false;
|
||||
} else if matches!(mode, ObserveMode::PendingFrontier) {
|
||||
let _ = runtime_tx.send(RuntimeCommand::ObservePendingFrontier);
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_termination(
|
||||
runtime_tx: &std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
|
||||
runtime_terminate_handle: &v8::IsolateHandle,
|
||||
cancellation_token: &CancellationToken,
|
||||
) {
|
||||
cancellation_token.cancel();
|
||||
let _ = runtime_tx.send(RuntimeCommand::Terminate);
|
||||
let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate);
|
||||
let _ = runtime_terminate_handle.terminate_execution();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,692 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_code_mode_protocol::ExecuteRequest;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::session_runtime::OutputItem;
|
||||
|
||||
struct TestHost;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingHost {
|
||||
notified: AtomicBool,
|
||||
}
|
||||
|
||||
impl CellHost for TestHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
_invocation: CellToolCall,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
Err("unexpected tool call".to_string())
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
_call_id: String,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, JsonValue>,
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
cell_state.commit_completion(event, pending_initial_yield_items, || {})
|
||||
}
|
||||
|
||||
async fn closed(&self) {}
|
||||
}
|
||||
|
||||
impl CellHost for RecordingHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
_invocation: CellToolCall,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
Err("unexpected tool call".to_string())
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
_call_id: String,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
self.notified.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, JsonValue>,
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
cell_state.commit_completion(event, pending_initial_yield_items, || {})
|
||||
}
|
||||
|
||||
async fn closed(&self) {}
|
||||
}
|
||||
|
||||
struct CellActorHarness {
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
handle: CellHandle,
|
||||
initial_event_rx: oneshot::Receiver<Result<CellEvent, CellError>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
runtime_control_rx: std_mpsc::Receiver<RuntimeControlCommand>,
|
||||
_runtime_event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
|
||||
}
|
||||
|
||||
fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness {
|
||||
spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost))
|
||||
}
|
||||
|
||||
fn spawn_cell_actor_harness_with_host<H: CellHost>(
|
||||
initial_observe_mode: ObserveMode,
|
||||
host: Arc<H>,
|
||||
) -> CellActorHarness {
|
||||
spawn_cell_actor_harness_with_host_and_failure_handler(
|
||||
initial_observe_mode,
|
||||
host,
|
||||
/*task_failure_handler*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_cell_actor_harness_with_host_and_failure_handler<H: CellHost>(
|
||||
initial_observe_mode: ObserveMode,
|
||||
host: Arc<H>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> CellActorHarness {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
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();
|
||||
let (runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
|
||||
HashMap::new(),
|
||||
ExecuteRequest {
|
||||
tool_call_id: "call-1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: "await new Promise(() => {});".to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
},
|
||||
runtime_event_tx,
|
||||
PendingRuntimeMode::PauseUntilResumed,
|
||||
/*task_failure_handler*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
let (runtime_control_tx, runtime_control_rx) = std_mpsc::channel();
|
||||
let cell_state = Arc::new(CellState::new(CancellationToken::new()));
|
||||
let handle = CellHandle::new(command_tx, Arc::clone(&cell_state));
|
||||
let task = tokio::spawn(run_cell(
|
||||
host,
|
||||
CellContext {
|
||||
runtime_tx,
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
},
|
||||
event_rx,
|
||||
command_rx,
|
||||
Observer {
|
||||
mode: initial_observe_mode,
|
||||
response_tx: initial_event_tx,
|
||||
},
|
||||
task_failure_handler,
|
||||
));
|
||||
|
||||
CellActorHarness {
|
||||
event_tx,
|
||||
handle,
|
||||
initial_event_rx,
|
||||
task,
|
||||
runtime_control_rx,
|
||||
_runtime_event_rx: runtime_event_rx,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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),
|
||||
Some(Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
);
|
||||
drop(harness.event_tx);
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), failure_rx.recv())
|
||||
.await
|
||||
.expect("runtime failure timeout")
|
||||
.expect("runtime failure"),
|
||||
"code-mode V8 runtime thread ended unexpectedly"
|
||||
);
|
||||
assert!(
|
||||
harness
|
||||
.initial_event_rx
|
||||
.await
|
||||
.expect("initial event")
|
||||
.is_ok()
|
||||
);
|
||||
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)
|
||||
.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(),
|
||||
error_text: Some("exec runtime ended unexpectedly".to_string()),
|
||||
})
|
||||
);
|
||||
harness.task.await.expect("cell task");
|
||||
}
|
||||
|
||||
async fn wait_for_notification(host: &RecordingHost) {
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while !host.notified.load(Ordering::Acquire) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("notification barrier timed out");
|
||||
}
|
||||
|
||||
#[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(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.initial_event_rx.await.unwrap(),
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
|
||||
let termination = harness.handle.terminate();
|
||||
drop(harness.event_tx);
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "queued output".to_string(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_termination_preempts_unobserved_runtime_completion() {
|
||||
let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::from_secs(60)));
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Result {
|
||||
stored_value_writes: HashMap::new(),
|
||||
error_text: None,
|
||||
})
|
||||
.unwrap();
|
||||
let termination = harness.handle.terminate();
|
||||
|
||||
let terminated = Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
});
|
||||
assert_eq!(termination.await, terminated.clone());
|
||||
assert_eq!(harness.initial_event_rx.await.unwrap(), terminated);
|
||||
harness.task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_dropped_before_dequeue_does_not_consume_output() {
|
||||
let host = Arc::new(RecordingHost::default());
|
||||
let harness = spawn_cell_actor_harness_with_host(
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
drop(
|
||||
harness
|
||||
.handle
|
||||
.observe(ObserveMode::YieldAfter(Duration::from_secs(60))),
|
||||
);
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "survives pre-dequeue cancellation".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-command".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
wait_for_notification(&host).await;
|
||||
|
||||
assert_eq!(
|
||||
harness
|
||||
.handle
|
||||
.observe(ObserveMode::YieldAfter(Duration::ZERO))
|
||||
.await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "survives pre-dequeue cancellation".to_string(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
|
||||
let termination = harness.handle.terminate();
|
||||
drop(harness.event_tx);
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_yield_observer_preserves_output_for_the_next_observation() {
|
||||
let host = Arc::new(RecordingHost::default());
|
||||
let harness = spawn_cell_actor_harness_with_host(
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
let dropped_observation = harness
|
||||
.handle
|
||||
.observe(ObserveMode::YieldAfter(Duration::from_secs(60)));
|
||||
assert_eq!(
|
||||
harness
|
||||
.handle
|
||||
.observe(ObserveMode::YieldAfter(Duration::ZERO))
|
||||
.await,
|
||||
Err(CellError::Busy)
|
||||
);
|
||||
drop(dropped_observation);
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "survives active cancellation".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-observer".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
wait_for_notification(&host).await;
|
||||
|
||||
assert_eq!(
|
||||
harness
|
||||
.handle
|
||||
.observe(ObserveMode::YieldAfter(Duration::ZERO))
|
||||
.await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "survives active cancellation".to_string(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
|
||||
let termination = harness.handle.terminate();
|
||||
drop(harness.event_tx);
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observation() {
|
||||
let host = Arc::new(RecordingHost::default());
|
||||
let harness = spawn_cell_actor_harness_with_host(
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
let dropped_observation = harness.handle.observe(ObserveMode::PendingFrontier);
|
||||
assert_eq!(
|
||||
harness.handle.observe(ObserveMode::PendingFrontier).await,
|
||||
Err(CellError::Busy)
|
||||
);
|
||||
drop(dropped_observation);
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ToolCall {
|
||||
id: "tool-1".to_string(),
|
||||
name: codex_protocol::ToolName {
|
||||
name: "echo".to_string(),
|
||||
namespace: None,
|
||||
},
|
||||
kind: codex_code_mode_protocol::CodeModeToolKind::Function,
|
||||
input: Some(serde_json::json!({})),
|
||||
})
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::Pending).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-pending".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
wait_for_notification(&host).await;
|
||||
|
||||
assert_eq!(
|
||||
harness.handle.observe(ObserveMode::PendingFrontier).await,
|
||||
Ok(CellEvent::Pending {
|
||||
content_items: Vec::new(),
|
||||
pending_tool_call_ids: vec!["tool-1".to_string()],
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
harness.runtime_control_rx.try_recv(),
|
||||
Err(std_mpsc::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
let termination = harness.handle.terminate();
|
||||
drop(harness.event_tx);
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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(),
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
completion.clone(),
|
||||
/*pending_initial_yield_items*/ None,
|
||||
|| {}
|
||||
),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
assert!(matches!(
|
||||
cell_state.deliver_completion(/*response_tx*/ None),
|
||||
CompletionDelivery::Buffered
|
||||
));
|
||||
|
||||
let first_termination = cell_state.request_termination();
|
||||
assert_eq!(
|
||||
cell_state.request_termination().await,
|
||||
Err(CellError::AlreadyTerminating)
|
||||
);
|
||||
assert_eq!(first_termination.await, Ok(completion.clone()));
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
}),
|
||||
Some(completion)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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(),
|
||||
error_text: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
completion.clone(),
|
||||
/*pending_initial_yield_items*/ None,
|
||||
|| commit_ran = true
|
||||
),
|
||||
CompletionCommit::Rejected(completion)
|
||||
);
|
||||
assert!(!commit_ran);
|
||||
|
||||
let terminated = CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(terminated.clone()),
|
||||
Some(terminated.clone())
|
||||
);
|
||||
assert_eq!(termination.await, Ok(terminated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_completion_delivery_rebuffers_the_event() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let event = CellEvent::Completed {
|
||||
content_items: Vec::new(),
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
event.clone(),
|
||||
/*pending_initial_yield_items*/ None,
|
||||
|| {}
|
||||
),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
drop(response_rx);
|
||||
assert!(matches!(
|
||||
cell_state.deliver_completion(Some(response_tx)),
|
||||
CompletionDelivery::Buffered
|
||||
));
|
||||
assert!(cell_state.accepting_observations());
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx),
|
||||
ObservationDelivery::Delivered
|
||||
));
|
||||
assert_eq!(response_rx.try_recv(), Ok(Ok(event)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_initial_yield_precedes_buffered_completion_for_yield_observer() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "after".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
completion.clone(),
|
||||
Some(vec![OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
}]),
|
||||
|| {}
|
||||
),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
assert!(matches!(
|
||||
cell_state.deliver_completion(/*response_tx*/ None),
|
||||
CompletionDelivery::Buffered
|
||||
));
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx),
|
||||
ObservationDelivery::Buffered
|
||||
));
|
||||
assert_eq!(
|
||||
response_rx.try_recv(),
|
||||
Ok(Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
}],
|
||||
}))
|
||||
);
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx),
|
||||
ObservationDelivery::Delivered
|
||||
));
|
||||
assert_eq!(response_rx.try_recv(), Ok(Ok(completion)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_observer_merges_initial_yield_and_completion_output() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "after".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
},
|
||||
Some(vec![OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
}]),
|
||||
|| {}
|
||||
),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
assert!(matches!(
|
||||
cell_state.deliver_completion(/*response_tx*/ None),
|
||||
CompletionDelivery::Buffered
|
||||
));
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::PendingFrontier, response_tx),
|
||||
ObservationDelivery::Delivered
|
||||
));
|
||||
assert_eq!(
|
||||
response_rx.try_recv(),
|
||||
Ok(Ok(CellEvent::Completed {
|
||||
content_items: vec![
|
||||
OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
OutputItem::Text {
|
||||
text: "after".to_string(),
|
||||
},
|
||||
],
|
||||
error_text: None,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_pending_observation_preserves_the_initial_yield_boundary() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "after".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(
|
||||
completion.clone(),
|
||||
Some(vec![OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
}]),
|
||||
|| {}
|
||||
),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
assert!(matches!(
|
||||
cell_state.deliver_completion(/*response_tx*/ None),
|
||||
CompletionDelivery::Buffered
|
||||
));
|
||||
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
drop(response_rx);
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::PendingFrontier, response_tx),
|
||||
ObservationDelivery::Buffered
|
||||
));
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx),
|
||||
ObservationDelivery::Buffered
|
||||
));
|
||||
assert_eq!(
|
||||
response_rx.try_recv(),
|
||||
Ok(Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "before".to_string(),
|
||||
}],
|
||||
}))
|
||||
);
|
||||
|
||||
let (response_tx, mut response_rx) = oneshot::channel();
|
||||
assert!(matches!(
|
||||
cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx),
|
||||
ObservationDelivery::Delivered
|
||||
));
|
||||
assert_eq!(response_rx.try_recv(), Ok(Ok(completion)));
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::session_runtime::CellEvent;
|
||||
use crate::session_runtime::ObserveMode;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use crate::session_runtime::ToolKind;
|
||||
use crate::session_runtime::ToolName;
|
||||
|
||||
pub(crate) type CellEventFuture =
|
||||
Pin<Box<dyn Future<Output = Result<CellEvent, CellError>> + Send + 'static>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CellError {
|
||||
Busy,
|
||||
AlreadyTerminating,
|
||||
Closed,
|
||||
}
|
||||
|
||||
pub(crate) struct CellToolCall {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: ToolName,
|
||||
pub(crate) kind: ToolKind,
|
||||
pub(crate) input: Option<JsonValue>,
|
||||
}
|
||||
|
||||
/// Connects a cell actor to session-owned callbacks and stored values.
|
||||
///
|
||||
/// Implementations should forward callback cancellation to downstream work.
|
||||
/// Implementations must not return from `closed` until the session can no longer
|
||||
/// route requests to the cell.
|
||||
pub(crate) trait CellHost: Send + Sync + 'static {
|
||||
fn invoke_tool(
|
||||
&self,
|
||||
invocation: CellToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl Future<Output = Result<JsonValue, String>> + Send;
|
||||
|
||||
fn notify(
|
||||
&self,
|
||||
call_id: String,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl Future<Output = Result<(), String>> + Send;
|
||||
|
||||
fn commit_completion(
|
||||
&self,
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> impl Future<Output = CompletionCommit> + Send;
|
||||
|
||||
fn closed(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CellHandle {
|
||||
command_tx: mpsc::UnboundedSender<CellCommand>,
|
||||
state: Arc<CellState>,
|
||||
}
|
||||
|
||||
impl CellHandle {
|
||||
pub(super) fn new(
|
||||
command_tx: mpsc::UnboundedSender<CellCommand>,
|
||||
state: Arc<CellState>,
|
||||
) -> Self {
|
||||
Self { command_tx, state }
|
||||
}
|
||||
|
||||
pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture {
|
||||
if !self.state.accepting_observations() {
|
||||
return closed_event();
|
||||
}
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if self
|
||||
.command_tx
|
||||
.send(CellCommand::Observe { mode, response_tx })
|
||||
.is_err()
|
||||
{
|
||||
return closed_event();
|
||||
}
|
||||
response_event(response_rx)
|
||||
}
|
||||
|
||||
pub(crate) fn terminate(&self) -> CellEventFuture {
|
||||
self.state.request_termination()
|
||||
}
|
||||
}
|
||||
|
||||
/// The single linearization point for a cell's terminal outcome.
|
||||
///
|
||||
/// The cancellation token is a child of the owning session token. Callback
|
||||
/// tokens are children of this token, so cancellation flows strictly from the
|
||||
/// session to the cell and then to its callbacks.
|
||||
///
|
||||
/// The mutex is held only for synchronous phase transitions and terminal
|
||||
/// delivery. Runtime execution, observation waits, and callbacks never run
|
||||
/// while it is held.
|
||||
pub(crate) struct CellState {
|
||||
phase: Mutex<CellPhase>,
|
||||
cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
enum CellPhase {
|
||||
Running,
|
||||
Terminating {
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
},
|
||||
Completed {
|
||||
// Set only when `yield_control()` races the create-to-first-observe handoff.
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
event: CellEvent,
|
||||
},
|
||||
CompletionClaimed(CellEvent),
|
||||
Tombstone,
|
||||
}
|
||||
|
||||
pub(crate) enum CompletionDelivery {
|
||||
Delivered,
|
||||
Buffered,
|
||||
Rejected(Option<oneshot::Sender<Result<CellEvent, CellError>>>),
|
||||
}
|
||||
|
||||
/// Result of atomically publishing a completed cell and its session side effects.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum CompletionCommit {
|
||||
Committed,
|
||||
Rejected(CellEvent),
|
||||
}
|
||||
|
||||
pub(crate) enum ObservationDelivery {
|
||||
Running(oneshot::Sender<Result<CellEvent, CellError>>),
|
||||
Delivered,
|
||||
Buffered,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl CellState {
|
||||
pub(crate) fn new(cancellation_token: CancellationToken) -> Self {
|
||||
Self {
|
||||
phase: Mutex::new(CellPhase::Running),
|
||||
cancellation_token,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn accepting_observations(&self) -> bool {
|
||||
let accepting_phase = matches!(
|
||||
*self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
CellPhase::Running | CellPhase::Completed { .. }
|
||||
);
|
||||
accepting_phase && !self.cancellation_token.is_cancelled()
|
||||
}
|
||||
|
||||
pub(crate) fn request_termination(&self) -> CellEventFuture {
|
||||
let mut phase = self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
|
||||
CellPhase::Running => {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
*phase = CellPhase::Terminating { response_tx };
|
||||
self.cancellation_token.cancel();
|
||||
response_event(response_rx)
|
||||
}
|
||||
CellPhase::Terminating { response_tx } => {
|
||||
*phase = CellPhase::Terminating { response_tx };
|
||||
Box::pin(async { Err(CellError::AlreadyTerminating) })
|
||||
}
|
||||
CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
} => {
|
||||
let event = prepend_initial_yield(event, pending_initial_yield_items);
|
||||
*phase = CellPhase::CompletionClaimed(event.clone());
|
||||
self.cancellation_token.cancel();
|
||||
ready_event(event)
|
||||
}
|
||||
CellPhase::CompletionClaimed(event) => {
|
||||
*phase = CellPhase::CompletionClaimed(event);
|
||||
Box::pin(async { Err(CellError::AlreadyTerminating) })
|
||||
}
|
||||
CellPhase::Tombstone => closed_event(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_completion(
|
||||
&self,
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
commit: impl FnOnce(),
|
||||
) -> CompletionCommit {
|
||||
let mut phase = self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() {
|
||||
return CompletionCommit::Rejected(event);
|
||||
}
|
||||
commit();
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
};
|
||||
CompletionCommit::Committed
|
||||
}
|
||||
|
||||
pub(crate) fn deliver_completion(
|
||||
&self,
|
||||
response_tx: Option<oneshot::Sender<Result<CellEvent, CellError>>>,
|
||||
) -> CompletionDelivery {
|
||||
let mut phase = self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (pending_initial_yield_items, event) =
|
||||
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
|
||||
CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
} => (pending_initial_yield_items, event),
|
||||
previous => {
|
||||
*phase = previous;
|
||||
return CompletionDelivery::Rejected(response_tx);
|
||||
}
|
||||
};
|
||||
let Some(response_tx) = response_tx else {
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
};
|
||||
return CompletionDelivery::Buffered;
|
||||
};
|
||||
match response_tx.send(Ok(event)) {
|
||||
Ok(()) => {
|
||||
self.cancellation_token.cancel();
|
||||
CompletionDelivery::Delivered
|
||||
}
|
||||
Err(Ok(event)) => {
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
};
|
||||
CompletionDelivery::Buffered
|
||||
}
|
||||
Err(Err(error)) => {
|
||||
panic!("completion delivery unexpectedly carried an actor error: {error:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn route_observation(
|
||||
&self,
|
||||
mode: ObserveMode,
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
) -> ObservationDelivery {
|
||||
let mut phase = self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
|
||||
CellPhase::Running => {
|
||||
*phase = CellPhase::Running;
|
||||
ObservationDelivery::Running(response_tx)
|
||||
}
|
||||
CellPhase::Completed {
|
||||
pending_initial_yield_items: Some(content_items),
|
||||
event,
|
||||
} if matches!(mode, ObserveMode::YieldAfter(_)) => {
|
||||
match response_tx.send(Ok(CellEvent::Yielded { content_items })) {
|
||||
Ok(()) => {
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items: None,
|
||||
event,
|
||||
};
|
||||
ObservationDelivery::Buffered
|
||||
}
|
||||
Err(Ok(CellEvent::Yielded { content_items })) => {
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items: Some(content_items),
|
||||
event,
|
||||
};
|
||||
ObservationDelivery::Buffered
|
||||
}
|
||||
Err(Ok(event)) => {
|
||||
panic!("initial yield delivery returned an unexpected event: {event:?}")
|
||||
}
|
||||
Err(Err(error)) => {
|
||||
panic!("initial yield delivery returned an actor error: {error:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
} => {
|
||||
let delivered_event =
|
||||
prepend_initial_yield(event.clone(), pending_initial_yield_items.clone());
|
||||
match response_tx.send(Ok(delivered_event)) {
|
||||
Ok(()) => {
|
||||
self.cancellation_token.cancel();
|
||||
ObservationDelivery::Delivered
|
||||
}
|
||||
Err(Ok(_)) => {
|
||||
*phase = CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
};
|
||||
ObservationDelivery::Buffered
|
||||
}
|
||||
Err(Err(error)) => {
|
||||
panic!("completion delivery unexpectedly carried an actor error: {error:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
CellPhase::Terminating {
|
||||
response_tx: termination_tx,
|
||||
} => {
|
||||
*phase = CellPhase::Terminating {
|
||||
response_tx: termination_tx,
|
||||
};
|
||||
let _ = response_tx.send(Err(CellError::Closed));
|
||||
ObservationDelivery::Closed
|
||||
}
|
||||
CellPhase::CompletionClaimed(event) => {
|
||||
*phase = CellPhase::CompletionClaimed(event);
|
||||
let _ = response_tx.send(Err(CellError::Closed));
|
||||
ObservationDelivery::Closed
|
||||
}
|
||||
CellPhase::Tombstone => {
|
||||
let _ = response_tx.send(Err(CellError::Closed));
|
||||
ObservationDelivery::Closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish_termination(&self, event: CellEvent) -> Option<CellEvent> {
|
||||
let mut phase = self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let observer_event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
|
||||
CellPhase::Running => Some(event),
|
||||
CellPhase::Terminating { response_tx } => {
|
||||
let _ = response_tx.send(Ok(event.clone()));
|
||||
Some(event)
|
||||
}
|
||||
CellPhase::Completed {
|
||||
pending_initial_yield_items,
|
||||
event,
|
||||
} => Some(prepend_initial_yield(event, pending_initial_yield_items)),
|
||||
CellPhase::CompletionClaimed(completed_event) => Some(completed_event),
|
||||
CellPhase::Tombstone => None,
|
||||
};
|
||||
self.cancellation_token.cancel();
|
||||
observer_event
|
||||
}
|
||||
|
||||
pub(crate) fn tombstone(&self) {
|
||||
*self
|
||||
.phase
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = CellPhase::Tombstone;
|
||||
self.cancellation_token.cancel();
|
||||
}
|
||||
|
||||
pub(crate) fn cancellation_token(&self) -> CancellationToken {
|
||||
self.cancellation_token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_initial_yield(
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
) -> CellEvent {
|
||||
let Some(mut pending_initial_yield_items) = pending_initial_yield_items else {
|
||||
return event;
|
||||
};
|
||||
match event {
|
||||
CellEvent::Yielded { mut content_items } => {
|
||||
pending_initial_yield_items.append(&mut content_items);
|
||||
CellEvent::Yielded {
|
||||
content_items: pending_initial_yield_items,
|
||||
}
|
||||
}
|
||||
CellEvent::Pending {
|
||||
mut content_items,
|
||||
pending_tool_call_ids,
|
||||
} => {
|
||||
pending_initial_yield_items.append(&mut content_items);
|
||||
CellEvent::Pending {
|
||||
content_items: pending_initial_yield_items,
|
||||
pending_tool_call_ids,
|
||||
}
|
||||
}
|
||||
CellEvent::Completed {
|
||||
mut content_items,
|
||||
error_text,
|
||||
} => {
|
||||
pending_initial_yield_items.append(&mut content_items);
|
||||
CellEvent::Completed {
|
||||
content_items: pending_initial_yield_items,
|
||||
error_text,
|
||||
}
|
||||
}
|
||||
CellEvent::Terminated { mut content_items } => {
|
||||
pending_initial_yield_items.append(&mut content_items);
|
||||
CellEvent::Terminated {
|
||||
content_items: pending_initial_yield_items,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum CellCommand {
|
||||
Observe {
|
||||
mode: ObserveMode,
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
},
|
||||
}
|
||||
|
||||
fn response_event(response_rx: oneshot::Receiver<Result<CellEvent, CellError>>) -> CellEventFuture {
|
||||
Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) })
|
||||
}
|
||||
|
||||
fn ready_event(event: CellEvent) -> CellEventFuture {
|
||||
Box::pin(async move { Ok(event) })
|
||||
}
|
||||
|
||||
fn closed_event() -> CellEventFuture {
|
||||
Box::pin(async { Err(CellError::Closed) })
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
mod cell_actor;
|
||||
mod remote_session;
|
||||
mod runtime;
|
||||
mod service;
|
||||
mod session_runtime;
|
||||
mod v8_init;
|
||||
|
||||
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
pub use codex_code_mode_protocol::*;
|
||||
pub use remote_session::DisabledCodeModeSessionProvider;
|
||||
pub use remote_session::ProcessOwnedCodeModeSession;
|
||||
pub use remote_session::ProcessOwnedCodeModeSessionProvider;
|
||||
pub use remote_session::WebSocketCodeModeSessionProvider;
|
||||
pub use service::InProcessCodeModeSession;
|
||||
pub use service::InProcessCodeModeSessionProvider;
|
||||
pub use service::NoopCodeModeSessionDelegate;
|
||||
pub use v8_init::V8JitMode;
|
||||
pub use v8_init::initialize_v8;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -20,6 +19,7 @@ use codex_code_mode_protocol::WaitRequest;
|
||||
use codex_code_mode_protocol::host::SessionId;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_install_context::InstallContext;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::watch;
|
||||
|
||||
@@ -31,87 +31,74 @@ use crate::NoopCodeModeSessionDelegate;
|
||||
|
||||
mod connection;
|
||||
|
||||
const CODE_MODE_HOST_PATH_ENV: &str = "CODEX_CODE_MODE_HOST_PATH";
|
||||
|
||||
type ShutdownResultReceiver = watch::Receiver<Option<Result<(), String>>>;
|
||||
|
||||
/// Creates code-mode sessions backed by one lazily spawned process host.
|
||||
pub struct ProcessOwnedCodeModeSessionProvider {
|
||||
state: StdMutex<ProviderState>,
|
||||
allow_in_process_fallback: bool,
|
||||
host: Arc<OwnedCodeModeHost>,
|
||||
}
|
||||
|
||||
/// Rejects code-mode sessions when the standalone host is disabled.
|
||||
#[derive(Default)]
|
||||
pub struct DisabledCodeModeSessionProvider;
|
||||
|
||||
/// Creates code-mode sessions backed by one shared remote WebSocket connection.
|
||||
pub struct WebSocketCodeModeSessionProvider {
|
||||
host: Arc<OwnedCodeModeHost>,
|
||||
}
|
||||
|
||||
enum ProviderState {
|
||||
OwnedProcess(Arc<OwnedCodeModeHost>),
|
||||
InProcess,
|
||||
}
|
||||
|
||||
impl ProcessOwnedCodeModeSessionProvider {
|
||||
pub fn with_host_program(host_program: PathBuf) -> Self {
|
||||
Self {
|
||||
state: StdMutex::new(ProviderState::OwnedProcess(Arc::new(
|
||||
OwnedCodeModeHost::new(host_program),
|
||||
))),
|
||||
allow_in_process_fallback: true,
|
||||
host: Arc::new(OwnedCodeModeHost::new(host_program)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn without_in_process_fallback(mut self) -> Self {
|
||||
self.allow_in_process_fallback = false;
|
||||
self
|
||||
}
|
||||
|
||||
fn process_host(&self) -> Option<Arc<OwnedCodeModeHost>> {
|
||||
match &*self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
{
|
||||
ProviderState::OwnedProcess(process_host) => Some(Arc::clone(process_host)),
|
||||
ProviderState::InProcess => None,
|
||||
}
|
||||
fn process_host(&self) -> Arc<OwnedCodeModeHost> {
|
||||
Arc::clone(&self.host)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessOwnedCodeModeSessionProvider {
|
||||
fn default() -> Self {
|
||||
Self::with_host_program(default_host_program())
|
||||
Self::with_host_program(InstallContext::current().code_mode_host_program())
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider {
|
||||
fn availability(&self) -> Result<(), String> {
|
||||
let HostEndpoint::Process(host_program) = &self.host.endpoint else {
|
||||
unreachable!("a process-owned provider always has a process endpoint");
|
||||
};
|
||||
if host_program.is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConnectionError::Spawn {
|
||||
host_program: host_program.clone(),
|
||||
error: io::Error::new(io::ErrorKind::NotFound, "host executable was not found"),
|
||||
}
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_session<'a>(
|
||||
&'a self,
|
||||
delegate: Arc<dyn CodeModeSessionDelegate>,
|
||||
) -> CodeModeSessionProviderFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let Some(process_host) = self.process_host() else {
|
||||
let session: Arc<dyn CodeModeSession> =
|
||||
Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate));
|
||||
return Ok(session);
|
||||
};
|
||||
Box::pin(create_host_session(delegate, self.process_host()))
|
||||
}
|
||||
}
|
||||
|
||||
match process_host.connection().await {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.host_program_not_found() && self.allow_in_process_fallback => {
|
||||
*self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
ProviderState::InProcess;
|
||||
let session: Arc<dyn CodeModeSession> =
|
||||
Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate));
|
||||
return Ok(session);
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
create_host_session(delegate, process_host).await
|
||||
})
|
||||
impl CodeModeSessionProvider for DisabledCodeModeSessionProvider {
|
||||
fn availability(&self) -> Result<(), String> {
|
||||
Err("code-mode host is disabled".to_string())
|
||||
}
|
||||
|
||||
fn create_session<'a>(
|
||||
&'a self,
|
||||
_delegate: Arc<dyn CodeModeSessionDelegate>,
|
||||
) -> CodeModeSessionProviderFuture<'a> {
|
||||
Box::pin(async { Err("code-mode host is disabled".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +260,9 @@ impl ProcessOwnedCodeModeSession {
|
||||
pub fn new() -> Self {
|
||||
Self::with_host(
|
||||
Arc::new(NoopCodeModeSessionDelegate),
|
||||
Arc::new(OwnedCodeModeHost::new(default_host_program())),
|
||||
Arc::new(OwnedCodeModeHost::new(
|
||||
InstallContext::current().code_mode_host_program(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -569,33 +558,6 @@ impl CodeModeSession for ProcessOwnedCodeModeSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_host_program() -> PathBuf {
|
||||
resolve_host_program(
|
||||
std::env::var_os(CODE_MODE_HOST_PATH_ENV),
|
||||
std::env::current_exe(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_host_program(
|
||||
override_path: Option<OsString>,
|
||||
current_exe: io::Result<PathBuf>,
|
||||
) -> PathBuf {
|
||||
if let Some(path) = override_path {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
let executable_name = if cfg!(windows) {
|
||||
"codex-code-mode-host.exe"
|
||||
} else {
|
||||
"codex-code-mode-host"
|
||||
};
|
||||
if let Ok(current_exe) = current_exe
|
||||
&& let Some(parent) = current_exe.parent()
|
||||
{
|
||||
return parent.join(executable_name);
|
||||
}
|
||||
PathBuf::from(executable_name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remote_session_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -71,15 +71,6 @@ pub(super) enum ConnectionError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ConnectionError {
|
||||
pub(super) fn host_program_not_found(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Spawn { error, .. } if error.kind() == io::ErrorKind::NotFound
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ConnectionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
||||
@@ -33,67 +33,18 @@ use super::ProcessOwnedCodeModeSession;
|
||||
use super::ProcessOwnedCodeModeSessionProvider;
|
||||
use super::WebSocketCodeModeSessionProvider;
|
||||
use super::connection::ConnectionError;
|
||||
use super::resolve_host_program;
|
||||
use crate::NoopCodeModeSessionDelegate;
|
||||
|
||||
#[test]
|
||||
fn provider_reuses_its_live_process_host() {
|
||||
let provider = ProcessOwnedCodeModeSessionProvider::default();
|
||||
|
||||
let first = provider.process_host().expect("owned process host");
|
||||
let second = provider.process_host().expect("owned process host");
|
||||
let first = provider.process_host();
|
||||
let second = provider.process_host();
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_program_override_takes_precedence() {
|
||||
assert_eq!(
|
||||
resolve_host_program(
|
||||
Some("custom-code-mode-host".into()),
|
||||
Ok(PathBuf::from("/opt/codex/bin/codex")),
|
||||
),
|
||||
PathBuf::from("custom-code-mode-host")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_program_is_next_to_the_main_executable_even_when_missing() {
|
||||
let executable_name = if cfg!(windows) {
|
||||
"codex-code-mode-host.exe"
|
||||
} else {
|
||||
"codex-code-mode-host"
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_host_program(
|
||||
/*override_path*/ None,
|
||||
Ok(PathBuf::from("/opt/codex/bin/codex")),
|
||||
),
|
||||
PathBuf::from("/opt/codex/bin").join(executable_name)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_program_falls_back_to_its_name_when_main_executable_is_unknown() {
|
||||
let executable_name = if cfg!(windows) {
|
||||
"codex-code-mode-host.exe"
|
||||
} else {
|
||||
"codex-code-mode-host"
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_host_program(
|
||||
/*override_path*/ None,
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"missing executable"
|
||||
)),
|
||||
),
|
||||
PathBuf::from(executable_name)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_host_error_limits_the_displayed_path_to_512_bytes() {
|
||||
let executable = "codex-code-mode-host-does-not-exist";
|
||||
@@ -129,42 +80,6 @@ fn missing_host_error_preserves_utf8_boundaries_when_truncating_the_path() {
|
||||
assert!(displayed_path.len() <= 512);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_falls_back_to_in_process_session_when_host_is_missing() {
|
||||
let provider = ProcessOwnedCodeModeSessionProvider::with_host_program(
|
||||
"codex-code-mode-host-does-not-exist".into(),
|
||||
);
|
||||
|
||||
let session = provider
|
||||
.create_session(Arc::new(NoopCodeModeSessionDelegate))
|
||||
.await
|
||||
.expect("missing host should fall back to an in-process session");
|
||||
let response = session
|
||||
.execute(ExecuteRequest {
|
||||
tool_call_id: "call-1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: "text('fallback')".to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
})
|
||||
.await
|
||||
.expect("execute fallback session")
|
||||
.initial_response()
|
||||
.await
|
||||
.expect("read fallback response");
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: codex_code_mode_protocol::CellId::new("1".to_string()),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "fallback".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_provider_executes_over_shared_connector() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -310,20 +225,18 @@ async fn websocket_provider_executes_over_shared_connector() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_returns_missing_host_error_when_in_process_fallback_is_disabled() {
|
||||
async fn provider_returns_missing_host_error() {
|
||||
let provider = ProcessOwnedCodeModeSessionProvider::with_host_program(
|
||||
"codex-code-mode-host-does-not-exist".into(),
|
||||
)
|
||||
.without_in_process_fallback();
|
||||
);
|
||||
|
||||
let error = provider
|
||||
.create_session(Arc::new(NoopCodeModeSessionDelegate))
|
||||
.await
|
||||
.err()
|
||||
.expect("missing host should fail when in-process fallback is disabled");
|
||||
.expect("missing host should fail");
|
||||
|
||||
assert!(error.contains("failed to spawn code-mode host codex-code-mode-host-does-not-exist"));
|
||||
assert!(provider.process_host().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
|
||||
use super::EXIT_SENTINEL;
|
||||
use super::RuntimeEvent;
|
||||
use super::RuntimeState;
|
||||
use super::timers;
|
||||
use super::value::json_to_v8;
|
||||
use super::value::normalize_output_audio;
|
||||
use super::value::normalize_output_image;
|
||||
use super::value::serialize_output_text;
|
||||
use super::value::throw_type_error;
|
||||
use super::value::v8_value_to_json;
|
||||
|
||||
pub(super) fn tool_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let tool_index = match args.data().to_rust_string_lossy(scope).parse::<usize>() {
|
||||
Ok(tool_index) => tool_index,
|
||||
Err(_) => {
|
||||
throw_type_error(scope, "invalid tool callback data");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let input = if args.length() == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
v8_value_to_json(scope, args.get(0))
|
||||
};
|
||||
let input = match input {
|
||||
Ok(input) => input,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(resolver) = v8::PromiseResolver::new(scope) else {
|
||||
throw_type_error(scope, "failed to create tool promise");
|
||||
return;
|
||||
};
|
||||
let promise = resolver.get_promise(scope);
|
||||
|
||||
let resolver = v8::Global::new(scope, resolver);
|
||||
let (tool_name, tool_kind) = {
|
||||
let Some(state) = scope.get_slot::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
};
|
||||
let Some(tool) = state.enabled_tools.get(tool_index) else {
|
||||
throw_type_error(scope, "tool callback data is out of range");
|
||||
return;
|
||||
};
|
||||
(tool.tool_name.clone(), tool.kind)
|
||||
};
|
||||
|
||||
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
};
|
||||
let id = format!("tool-{}", state.next_tool_call_id);
|
||||
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 {
|
||||
id,
|
||||
name: tool_name,
|
||||
kind: tool_kind,
|
||||
input,
|
||||
});
|
||||
retval.set(promise.into());
|
||||
}
|
||||
|
||||
pub(super) fn text_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let text = match serialize_output_text(scope, value) {
|
||||
Ok(text) => text,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn audio_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let audio_item = match normalize_output_audio(scope, value) {
|
||||
Ok(audio_item) => audio_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(audio_item));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn image_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let detail_override = if args.length() < 2 {
|
||||
None
|
||||
} else {
|
||||
let detail = args.get(1);
|
||||
if detail.is_string() {
|
||||
Some(detail.to_rust_string_lossy(scope))
|
||||
} else if detail.is_null() || detail.is_undefined() {
|
||||
None
|
||||
} else {
|
||||
throw_type_error(scope, "image detail must be a string when provided");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let image_item = match normalize_output_image(scope, value, detail_override) {
|
||||
Ok(image_item) => image_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn generated_image_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let output_hint = match generated_image_output_hint(scope, value) {
|
||||
Ok(output_hint) => output_hint,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) {
|
||||
Ok(image_item) => image_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
if let Some(text) = output_hint {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
}
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
fn generated_image_output_hint(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let object = v8::Local::<v8::Object>::try_from(value)
|
||||
.map_err(|_| "generatedImage expects an image generation result object".to_string())?;
|
||||
let key = v8::String::new(scope, "output_hint")
|
||||
.ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?;
|
||||
let output_hint = object
|
||||
.get(scope, key.into())
|
||||
.ok_or_else(|| "failed to read generatedImage output_hint".to_string())?;
|
||||
if output_hint.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output_hint.is_string() {
|
||||
return Err("generatedImage output_hint must be a string when provided".to_string());
|
||||
}
|
||||
Ok(Some(output_hint.to_rust_string_lossy(scope)))
|
||||
}
|
||||
|
||||
pub(super) fn store_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let key = match args.get(0).to_string(scope) {
|
||||
Some(key) => key.to_rust_string_lossy(scope),
|
||||
None => {
|
||||
throw_type_error(scope, "store key must be a string");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value = args.get(1);
|
||||
let serialized = match v8_value_to_json(scope, value) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
throw_type_error(
|
||||
scope,
|
||||
&format!("Unable to store {key:?}. Only plain serializable objects can be stored."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(state) = scope.get_slot_mut::<RuntimeState>() {
|
||||
state.stored_values.insert(key.clone(), serialized.clone());
|
||||
state.stored_value_writes.insert(key, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn load_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let key = match args.get(0).to_string(scope) {
|
||||
Some(key) => key.to_rust_string_lossy(scope),
|
||||
None => {
|
||||
throw_type_error(scope, "load key must be a string");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.and_then(|state| state.stored_values.get(&key))
|
||||
.cloned();
|
||||
let Some(value) = value else {
|
||||
retval.set(v8::undefined(scope).into());
|
||||
return;
|
||||
};
|
||||
let Some(value) = json_to_v8(scope, &value) else {
|
||||
throw_type_error(scope, "failed to load stored value");
|
||||
return;
|
||||
};
|
||||
retval.set(value);
|
||||
}
|
||||
|
||||
pub(super) fn notify_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let text = match serialize_output_text(scope, value) {
|
||||
Ok(text) => text,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if text.trim().is_empty() {
|
||||
throw_type_error(scope, "notify expects non-empty text");
|
||||
return;
|
||||
}
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::Notify {
|
||||
call_id: state.tool_call_id.clone(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn set_timeout_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let timeout_id = match timers::schedule_timeout(scope, args) {
|
||||
Ok(timeout_id) => timeout_id,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
retval.set(v8::Number::new(scope, timeout_id as f64).into());
|
||||
}
|
||||
|
||||
pub(super) fn clear_timeout_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if let Err(error_text) = timers::clear_timeout(scope, args) {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn yield_control_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
_args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::YieldRequested);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exit_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
_args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if let Some(state) = scope.get_slot_mut::<RuntimeState>() {
|
||||
state.exit_requested = true;
|
||||
}
|
||||
if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) {
|
||||
scope.throw_exception(error.into());
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
use super::RuntimeState;
|
||||
use super::callbacks::audio_callback;
|
||||
use super::callbacks::clear_timeout_callback;
|
||||
use super::callbacks::exit_callback;
|
||||
use super::callbacks::generated_image_callback;
|
||||
use super::callbacks::image_callback;
|
||||
use super::callbacks::load_callback;
|
||||
use super::callbacks::notify_callback;
|
||||
use super::callbacks::set_timeout_callback;
|
||||
use super::callbacks::store_callback;
|
||||
use super::callbacks::text_callback;
|
||||
use super::callbacks::tool_callback;
|
||||
use super::callbacks::yield_control_callback;
|
||||
|
||||
pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
delete_global(scope, global, "console")?;
|
||||
delete_global(scope, global, "Atomics")?;
|
||||
delete_global(scope, global, "SharedArrayBuffer")?;
|
||||
delete_global(scope, global, "WebAssembly")?;
|
||||
|
||||
let tools = build_tools_object(scope)?;
|
||||
let all_tools = build_all_tools_value(scope)?;
|
||||
let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?;
|
||||
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
|
||||
let text = helper_function(scope, "text", text_callback)?;
|
||||
let image = helper_function(scope, "image", image_callback)?;
|
||||
let audio = helper_function(scope, "audio", audio_callback)?;
|
||||
let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?;
|
||||
let store = helper_function(scope, "store", store_callback)?;
|
||||
let load = helper_function(scope, "load", load_callback)?;
|
||||
let notify = helper_function(scope, "notify", notify_callback)?;
|
||||
let yield_control = helper_function(scope, "yield_control", yield_control_callback)?;
|
||||
let exit = helper_function(scope, "exit", exit_callback)?;
|
||||
|
||||
set_global(scope, global, "tools", tools.into())?;
|
||||
set_global(scope, global, "ALL_TOOLS", all_tools)?;
|
||||
set_global(scope, global, "clearTimeout", clear_timeout.into())?;
|
||||
set_global(scope, global, "setTimeout", set_timeout.into())?;
|
||||
set_global(scope, global, "text", text.into())?;
|
||||
set_global(scope, global, "image", image.into())?;
|
||||
set_global(scope, global, "audio", audio.into())?;
|
||||
set_global(scope, global, "generatedImage", generated_image.into())?;
|
||||
set_global(scope, global, "store", store.into())?;
|
||||
set_global(scope, global, "load", load.into())?;
|
||||
set_global(scope, global, "notify", notify.into())?;
|
||||
set_global(scope, global, "yield_control", yield_control.into())?;
|
||||
set_global(scope, global, "exit", exit.into())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_tools_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Object>, String> {
|
||||
let tools = v8::Object::new(scope);
|
||||
let enabled_tools = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.enabled_tools.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (tool_index, tool) in enabled_tools.iter().enumerate() {
|
||||
let name = v8::String::new(scope, &tool.global_name)
|
||||
.ok_or_else(|| "failed to allocate tool name".to_string())?;
|
||||
let function = tool_function(scope, tool_index)?;
|
||||
tools.set(scope, name.into(), function.into());
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
fn build_all_tools_value<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Value>, String> {
|
||||
let enabled_tools = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.enabled_tools.clone())
|
||||
.unwrap_or_default();
|
||||
let array = v8::Array::new(scope, enabled_tools.len() as i32);
|
||||
let name_key = v8::String::new(scope, "name")
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?;
|
||||
let description_key = v8::String::new(scope, "description")
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?;
|
||||
|
||||
for (index, tool) in enabled_tools.iter().enumerate() {
|
||||
let item = v8::Object::new(scope);
|
||||
let name = v8::String::new(scope, &tool.global_name)
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?;
|
||||
let description = v8::String::new(scope, &tool.description)
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?;
|
||||
|
||||
if item.set(scope, name_key.into(), name.into()) != Some(true) {
|
||||
return Err("failed to set ALL_TOOLS name".to_string());
|
||||
}
|
||||
if item.set(scope, description_key.into(), description.into()) != Some(true) {
|
||||
return Err("failed to set ALL_TOOLS description".to_string());
|
||||
}
|
||||
if array.set_index(scope, index as u32, item.into()) != Some(true) {
|
||||
return Err("failed to append ALL_TOOLS metadata".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(array.into())
|
||||
}
|
||||
|
||||
fn helper_function<'s, F>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
name: &str,
|
||||
callback: F,
|
||||
) -> Result<v8::Local<'s, v8::Function>, String>
|
||||
where
|
||||
F: v8::MapFnTo<v8::FunctionCallback>,
|
||||
{
|
||||
let name =
|
||||
v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?;
|
||||
let template = v8::FunctionTemplate::builder(callback)
|
||||
.data(name.into())
|
||||
.build(scope);
|
||||
template
|
||||
.get_function(scope)
|
||||
.ok_or_else(|| "failed to create helper function".to_string())
|
||||
}
|
||||
|
||||
fn tool_function<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
tool_index: usize,
|
||||
) -> Result<v8::Local<'s, v8::Function>, String> {
|
||||
let data = v8::String::new(scope, &tool_index.to_string())
|
||||
.ok_or_else(|| "failed to allocate tool callback data".to_string())?;
|
||||
let template = v8::FunctionTemplate::builder(tool_callback)
|
||||
.data(data.into())
|
||||
.build(scope);
|
||||
template
|
||||
.get_function(scope)
|
||||
.ok_or_else(|| "failed to create tool function".to_string())
|
||||
}
|
||||
|
||||
fn set_global<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
global: v8::Local<'s, v8::Object>,
|
||||
name: &str,
|
||||
value: v8::Local<'s, v8::Value>,
|
||||
) -> Result<(), String> {
|
||||
let key = v8::String::new(scope, name)
|
||||
.ok_or_else(|| format!("failed to allocate global `{name}`"))?;
|
||||
if global.set(scope, key.into(), value) == Some(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("failed to set global `{name}`"))
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_global<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
global: v8::Local<'s, v8::Object>,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let key = v8::String::new(scope, name)
|
||||
.ok_or_else(|| format!("failed to allocate global `{name}`"))?;
|
||||
if global.delete(scope, key.into()) == Some(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("failed to remove global `{name}`"))
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
mod callbacks;
|
||||
mod globals;
|
||||
mod module_loader;
|
||||
mod timers;
|
||||
mod value;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::panic::catch_unwind;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::thread;
|
||||
|
||||
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::enabled_tool_metadata;
|
||||
use codex_protocol::ToolName;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::v8_init::ensure_v8_initialized;
|
||||
|
||||
const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeCommand {
|
||||
ToolResponse { id: String, result: JsonValue },
|
||||
ToolError { id: String, error_text: String },
|
||||
TimeoutFired { id: u64 },
|
||||
ObservePendingFrontier,
|
||||
Terminate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PendingRuntimeMode {
|
||||
#[cfg(test)]
|
||||
Continue,
|
||||
PauseUntilResumed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeControlCommand {
|
||||
Continue,
|
||||
Resume,
|
||||
Terminate,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeEvent {
|
||||
Started,
|
||||
Pending,
|
||||
ContentItem(FunctionCallOutputContentItem),
|
||||
YieldRequested,
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: ToolName,
|
||||
kind: CodeModeToolKind,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
call_id: String,
|
||||
text: String,
|
||||
},
|
||||
Result {
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
ThreadPanicked,
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_runtime(
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
request: ExecuteRequest,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> Result<
|
||||
(
|
||||
std_mpsc::Sender<RuntimeCommand>,
|
||||
std_mpsc::Sender<RuntimeControlCommand>,
|
||||
v8::IsolateHandle,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
ensure_v8_initialized()?;
|
||||
|
||||
let (command_tx, command_rx) = std_mpsc::channel();
|
||||
let (control_tx, control_rx) = std_mpsc::channel();
|
||||
let runtime_command_tx = command_tx.clone();
|
||||
let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1);
|
||||
let enabled_tools = request
|
||||
.enabled_tools
|
||||
.iter()
|
||||
.map(enabled_tool_metadata)
|
||||
.collect::<Vec<_>>();
|
||||
let config = RuntimeConfig {
|
||||
tool_call_id: request.tool_call_id,
|
||||
enabled_tools,
|
||||
source: request.source,
|
||||
stored_values,
|
||||
};
|
||||
|
||||
spawn_supervised_runtime_thread(event_tx.clone(), task_failure_handler, move || {
|
||||
run_runtime(
|
||||
config,
|
||||
event_tx,
|
||||
command_rx,
|
||||
control_rx,
|
||||
pending_mode,
|
||||
isolate_handle_tx,
|
||||
runtime_command_tx,
|
||||
);
|
||||
});
|
||||
|
||||
let isolate_handle = isolate_handle_rx
|
||||
.recv()
|
||||
.map_err(|_| "failed to initialize code mode runtime".to_string())?;
|
||||
Ok((command_tx, control_tx, isolate_handle))
|
||||
}
|
||||
|
||||
fn spawn_supervised_runtime_thread(
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
runtime: impl FnOnce() + Send + 'static,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeConfig {
|
||||
tool_call_id: String,
|
||||
enabled_tools: Vec<EnabledToolMetadata>,
|
||||
source: String,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeState {
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
pending_tool_calls: HashMap<String, v8::Global<v8::PromiseResolver>>,
|
||||
pending_timeouts: HashMap<u64, timers::ScheduledTimeout>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
enabled_tools: Vec<EnabledToolMetadata>,
|
||||
next_tool_call_id: u64,
|
||||
next_timeout_id: u64,
|
||||
tool_call_id: String,
|
||||
runtime_command_tx: std_mpsc::Sender<RuntimeCommand>,
|
||||
exit_requested: bool,
|
||||
}
|
||||
|
||||
pub(super) enum CompletionState {
|
||||
Pending,
|
||||
Completed {
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn run_runtime(
|
||||
config: RuntimeConfig,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
command_rx: std_mpsc::Receiver<RuntimeCommand>,
|
||||
control_rx: std_mpsc::Receiver<RuntimeControlCommand>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
isolate_handle_tx: std_mpsc::SyncSender<v8::IsolateHandle>,
|
||||
runtime_command_tx: std_mpsc::Sender<RuntimeCommand>,
|
||||
) {
|
||||
let isolate = &mut v8::Isolate::new(v8::CreateParams::default());
|
||||
let isolate_handle = isolate.thread_safe_handle();
|
||||
if isolate_handle_tx.send(isolate_handle).is_err() {
|
||||
return;
|
||||
}
|
||||
isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback);
|
||||
|
||||
v8::scope!(let scope, isolate);
|
||||
let context = v8::Context::new(scope, Default::default());
|
||||
let scope = &mut v8::ContextScope::new(scope, context);
|
||||
|
||||
scope.set_slot(RuntimeState {
|
||||
event_tx: event_tx.clone(),
|
||||
pending_tool_calls: HashMap::new(),
|
||||
pending_timeouts: HashMap::new(),
|
||||
stored_values: config.stored_values,
|
||||
stored_value_writes: HashMap::new(),
|
||||
enabled_tools: config.enabled_tools,
|
||||
next_tool_call_id: 1,
|
||||
next_timeout_id: 1,
|
||||
tool_call_id: config.tool_call_id,
|
||||
runtime_command_tx,
|
||||
exit_requested: false,
|
||||
});
|
||||
|
||||
if let Err(error_text) = globals::install_globals(scope) {
|
||||
send_result(&event_tx, HashMap::new(), Some(error_text));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event_tx.send(RuntimeEvent::Started);
|
||||
|
||||
let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) {
|
||||
Ok(pending_promise) => pending_promise,
|
||||
Err(error_text) => {
|
||||
capture_scope_send_error(scope, &event_tx, Some(error_text));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match module_loader::completion_state(scope, pending_promise.as_ref()) {
|
||||
CompletionState::Completed {
|
||||
stored_value_writes,
|
||||
error_text,
|
||||
} => {
|
||||
send_result(&event_tx, stored_value_writes, error_text);
|
||||
return;
|
||||
}
|
||||
CompletionState::Pending => {}
|
||||
}
|
||||
|
||||
let mut pending_promise = pending_promise;
|
||||
while let Some(command) =
|
||||
next_runtime_command(&event_tx, &command_rx, &control_rx, pending_mode)
|
||||
{
|
||||
match command {
|
||||
RuntimeCommand::Terminate => break,
|
||||
RuntimeCommand::ToolResponse { id, result } => {
|
||||
if let Err(error_text) =
|
||||
module_loader::resolve_tool_response(scope, &id, Ok(result))
|
||||
{
|
||||
capture_scope_send_error(scope, &event_tx, Some(error_text));
|
||||
return;
|
||||
}
|
||||
}
|
||||
RuntimeCommand::ToolError { id, error_text } => {
|
||||
if let Err(runtime_error) =
|
||||
module_loader::resolve_tool_response(scope, &id, Err(error_text))
|
||||
{
|
||||
capture_scope_send_error(scope, &event_tx, Some(runtime_error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
RuntimeCommand::TimeoutFired { id } => {
|
||||
if let Err(runtime_error) = timers::invoke_timeout_callback(scope, id) {
|
||||
capture_scope_send_error(scope, &event_tx, Some(runtime_error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
RuntimeCommand::ObservePendingFrontier => {}
|
||||
}
|
||||
|
||||
scope.perform_microtask_checkpoint();
|
||||
match module_loader::completion_state(scope, pending_promise.as_ref()) {
|
||||
CompletionState::Completed {
|
||||
stored_value_writes,
|
||||
error_text,
|
||||
} => {
|
||||
send_result(&event_tx, stored_value_writes, error_text);
|
||||
return;
|
||||
}
|
||||
CompletionState::Pending => {}
|
||||
}
|
||||
|
||||
if let Some(promise) = pending_promise.as_ref() {
|
||||
let promise = v8::Local::new(scope, promise);
|
||||
if promise.state() != v8::PromiseState::Pending {
|
||||
pending_promise = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_runtime_command(
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
command_rx: &std_mpsc::Receiver<RuntimeCommand>,
|
||||
control_rx: &std_mpsc::Receiver<RuntimeControlCommand>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
) -> Option<RuntimeCommand> {
|
||||
loop {
|
||||
match command_rx.try_recv() {
|
||||
Ok(command) => return Some(command),
|
||||
Err(std_mpsc::TryRecvError::Disconnected) => return None,
|
||||
Err(std_mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
|
||||
let _ = event_tx.send(RuntimeEvent::Pending);
|
||||
match pending_mode {
|
||||
#[cfg(test)]
|
||||
PendingRuntimeMode::Continue => return command_rx.recv().ok(),
|
||||
PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? {
|
||||
RuntimeControlCommand::Continue => return command_rx.recv().ok(),
|
||||
RuntimeControlCommand::Resume => continue,
|
||||
RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_scope_send_error(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
let stored_value_writes = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.stored_value_writes.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
send_result(event_tx, stored_value_writes, error_text);
|
||||
}
|
||||
|
||||
fn send_result(
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
let _ = event_tx.send(RuntimeEvent::Result {
|
||||
stored_value_writes,
|
||||
error_text,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::ExecuteRequest;
|
||||
use super::PendingRuntimeMode;
|
||||
use super::RuntimeCommand;
|
||||
use super::RuntimeControlCommand;
|
||||
use super::RuntimeEvent;
|
||||
use super::spawn_runtime;
|
||||
use super::spawn_supervised_runtime_thread;
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
yield_time_ms: Some(1),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_thread_panic_before_initialization_is_reported_directly() {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
drop(event_rx);
|
||||
let (failure_tx, mut failure_rx) = mpsc::unbounded_channel();
|
||||
spawn_supervised_runtime_thread(
|
||||
event_tx,
|
||||
Some(std::sync::Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
|| panic!("runtime thread panic probe"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(1), failure_rx.recv())
|
||||
.await
|
||||
.expect("runtime failure timeout")
|
||||
.expect("runtime failure"),
|
||||
"code-mode V8 runtime thread panicked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_thread_panic_is_forwarded_without_owner_supervision() {
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
spawn_supervised_runtime_thread(
|
||||
event_tx,
|
||||
/*task_failure_handler*/ None,
|
||||
|| panic!("runtime thread panic probe"),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.expect("runtime panic event timeout"),
|
||||
Some(RuntimeEvent::ThreadPanicked)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_execution_stops_cpu_bound_module() {
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let (_runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
|
||||
HashMap::new(),
|
||||
execute_request("while (true) {}"),
|
||||
event_tx,
|
||||
PendingRuntimeMode::Continue,
|
||||
/*task_failure_handler*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(started_event, RuntimeEvent::Started));
|
||||
|
||||
assert!(runtime_terminate_handle.terminate_execution());
|
||||
|
||||
let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let RuntimeEvent::Result { error_text, .. } = result_event else {
|
||||
panic!("expected runtime result after termination");
|
||||
};
|
||||
assert!(error_text.is_some());
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_mode_freezes_runtime_commands_until_resume() {
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let (runtime_tx, runtime_control_tx, _runtime_terminate_handle) = spawn_runtime(
|
||||
HashMap::new(),
|
||||
execute_request(
|
||||
r#"
|
||||
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||||
text("after");
|
||||
await new Promise(() => {});
|
||||
"#,
|
||||
),
|
||||
event_tx,
|
||||
PendingRuntimeMode::PauseUntilResumed,
|
||||
/*task_failure_handler*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
RuntimeEvent::Started
|
||||
));
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
RuntimeEvent::Pending
|
||||
));
|
||||
|
||||
runtime_tx
|
||||
.send(RuntimeCommand::TimeoutFired { id: 1 })
|
||||
.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
runtime_control_tx
|
||||
.send(RuntimeControlCommand::Resume)
|
||||
.unwrap();
|
||||
|
||||
let content_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let RuntimeEvent::ContentItem(FunctionCallOutputContentItem::InputText { text }) =
|
||||
content_event
|
||||
else {
|
||||
panic!("expected resumed runtime output");
|
||||
};
|
||||
assert_eq!(text, "after");
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
RuntimeEvent::Pending
|
||||
));
|
||||
|
||||
runtime_control_tx
|
||||
.send(RuntimeControlCommand::Terminate)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use super::CompletionState;
|
||||
use super::EXIT_SENTINEL;
|
||||
use super::RuntimeState;
|
||||
use super::value::json_to_v8;
|
||||
use super::value::value_to_error_text;
|
||||
|
||||
pub(super) fn evaluate_main_module(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
source_text: &str,
|
||||
) -> Result<Option<v8::Global<v8::Promise>>, String> {
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let source = v8::String::new(&tc, source_text)
|
||||
.ok_or_else(|| "failed to allocate exec source".to_string())?;
|
||||
let origin = script_origin(&mut tc, "exec_main.mjs")?;
|
||||
let mut source = v8::script_compiler::Source::new(source, Some(&origin));
|
||||
let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| {
|
||||
tc.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string())
|
||||
})?;
|
||||
module
|
||||
.instantiate_module(&tc, resolve_module_callback)
|
||||
.ok_or_else(|| {
|
||||
tc.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string())
|
||||
})?;
|
||||
let result = match module.evaluate(&tc) {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
if let Some(exception) = tc.exception() {
|
||||
if is_exit_exception(&mut tc, exception) {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(value_to_error_text(&mut tc, exception));
|
||||
}
|
||||
return Err("unknown code mode exception".to_string());
|
||||
}
|
||||
};
|
||||
tc.perform_microtask_checkpoint();
|
||||
|
||||
if result.is_promise() {
|
||||
let promise = v8::Local::<v8::Promise>::try_from(result)
|
||||
.map_err(|_| "failed to read exec promise".to_string())?;
|
||||
return Ok(Some(v8::Global::new(&tc, promise)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_exit_exception(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
exception: v8::Local<'_, v8::Value>,
|
||||
) -> bool {
|
||||
scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.exit_requested)
|
||||
.unwrap_or(false)
|
||||
&& exception.is_string()
|
||||
&& exception.to_rust_string_lossy(scope) == EXIT_SENTINEL
|
||||
}
|
||||
|
||||
pub(super) fn resolve_tool_response(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
id: &str,
|
||||
response: Result<JsonValue, String>,
|
||||
) -> Result<(), String> {
|
||||
let resolver = {
|
||||
let state = scope
|
||||
.get_slot_mut::<RuntimeState>()
|
||||
.ok_or_else(|| "runtime state unavailable".to_string())?;
|
||||
state.pending_tool_calls.remove(id)
|
||||
}
|
||||
.ok_or_else(|| format!("unknown tool call `{id}`"))?;
|
||||
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let resolver = v8::Local::new(&tc, &resolver);
|
||||
match response {
|
||||
Ok(result) => {
|
||||
let value = json_to_v8(&mut tc, &result)
|
||||
.ok_or_else(|| "failed to serialize tool response".to_string())?;
|
||||
resolver.resolve(&tc, value);
|
||||
}
|
||||
Err(error_text) => {
|
||||
let value = v8::String::new(&tc, &error_text)
|
||||
.ok_or_else(|| "failed to allocate tool error".to_string())?;
|
||||
resolver.reject(&tc, value.into());
|
||||
}
|
||||
}
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn completion_state(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
pending_promise: Option<&v8::Global<v8::Promise>>,
|
||||
) -> CompletionState {
|
||||
let stored_value_writes = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.stored_value_writes.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let Some(pending_promise) = pending_promise else {
|
||||
return CompletionState::Completed {
|
||||
stored_value_writes,
|
||||
error_text: None,
|
||||
};
|
||||
};
|
||||
|
||||
let promise = v8::Local::new(scope, pending_promise);
|
||||
match promise.state() {
|
||||
v8::PromiseState::Pending => CompletionState::Pending,
|
||||
v8::PromiseState::Fulfilled => CompletionState::Completed {
|
||||
stored_value_writes,
|
||||
error_text: None,
|
||||
},
|
||||
v8::PromiseState::Rejected => {
|
||||
let result = promise.result(scope);
|
||||
let error_text = if is_exit_exception(scope, result) {
|
||||
None
|
||||
} else {
|
||||
Some(value_to_error_text(scope, result))
|
||||
};
|
||||
CompletionState::Completed {
|
||||
stored_value_writes,
|
||||
error_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script_origin<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
resource_name_: &str,
|
||||
) -> Result<v8::ScriptOrigin<'s>, String> {
|
||||
let resource_name = v8::String::new(scope, resource_name_)
|
||||
.ok_or_else(|| "failed to allocate script origin".to_string())?;
|
||||
let source_map_url = v8::String::new(scope, resource_name_)
|
||||
.ok_or_else(|| "failed to allocate source map url".to_string())?;
|
||||
Ok(v8::ScriptOrigin::new(
|
||||
scope,
|
||||
resource_name.into(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
Some(source_map_url.into()),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_module_callback<'s>(
|
||||
context: v8::Local<'s, v8::Context>,
|
||||
specifier: v8::Local<'s, v8::String>,
|
||||
_import_attributes: v8::Local<'s, v8::FixedArray>,
|
||||
_referrer: v8::Local<'s, v8::Module>,
|
||||
) -> Option<v8::Local<'s, v8::Module>> {
|
||||
v8::callback_scope!(unsafe scope, context);
|
||||
let specifier = specifier.to_rust_string_lossy(scope);
|
||||
resolve_module(scope, &specifier)
|
||||
}
|
||||
|
||||
pub(super) fn dynamic_import_callback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
_host_defined_options: v8::Local<'s, v8::Data>,
|
||||
_resource_name: v8::Local<'s, v8::Value>,
|
||||
specifier: v8::Local<'s, v8::String>,
|
||||
_import_attributes: v8::Local<'s, v8::FixedArray>,
|
||||
) -> Option<v8::Local<'s, v8::Promise>> {
|
||||
let specifier = specifier.to_rust_string_lossy(scope);
|
||||
let resolver = v8::PromiseResolver::new(scope)?;
|
||||
|
||||
match resolve_module(scope, &specifier) {
|
||||
Some(module) => {
|
||||
if module.get_status() == v8::ModuleStatus::Uninstantiated
|
||||
&& module
|
||||
.instantiate_module(scope, resolve_module_callback)
|
||||
.is_none()
|
||||
{
|
||||
let error = v8::String::new(scope, "failed to instantiate module")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
return Some(resolver.get_promise(scope));
|
||||
}
|
||||
if matches!(
|
||||
module.get_status(),
|
||||
v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated
|
||||
) && module.evaluate(scope).is_none()
|
||||
{
|
||||
let error = v8::String::new(scope, "failed to evaluate module")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
return Some(resolver.get_promise(scope));
|
||||
}
|
||||
let namespace = module.get_module_namespace();
|
||||
resolver.resolve(scope, namespace);
|
||||
Some(resolver.get_promise(scope))
|
||||
}
|
||||
None => {
|
||||
let error = v8::String::new(scope, "unsupported import in exec")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
Some(resolver.get_promise(scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_module<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
specifier: &str,
|
||||
) -> Option<v8::Local<'s, v8::Module>> {
|
||||
if let Some(message) =
|
||||
v8::String::new(scope, &format!("Unsupported import in exec: {specifier}"))
|
||||
{
|
||||
scope.throw_exception(message.into());
|
||||
} else {
|
||||
scope.throw_exception(v8::undefined(scope).into());
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::RuntimeCommand;
|
||||
use super::RuntimeState;
|
||||
use super::value::value_to_error_text;
|
||||
|
||||
pub(super) struct ScheduledTimeout {
|
||||
callback: v8::Global<v8::Function>,
|
||||
}
|
||||
|
||||
pub(super) fn schedule_timeout(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
) -> Result<u64, String> {
|
||||
let callback = args.get(0);
|
||||
if !callback.is_function() {
|
||||
return Err("setTimeout expects a function callback".to_string());
|
||||
}
|
||||
let callback = v8::Local::<v8::Function>::try_from(callback)
|
||||
.map_err(|_| "setTimeout expects a function callback".to_string())?;
|
||||
|
||||
let delay_ms = args
|
||||
.get(1)
|
||||
.number_value(scope)
|
||||
.map(normalize_delay_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
let callback = v8::Global::new(scope, callback);
|
||||
let state = scope
|
||||
.get_slot_mut::<RuntimeState>()
|
||||
.ok_or_else(|| "runtime state unavailable".to_string())?;
|
||||
let timeout_id = state.next_timeout_id;
|
||||
state.next_timeout_id = state.next_timeout_id.saturating_add(1);
|
||||
let runtime_command_tx = state.runtime_command_tx.clone();
|
||||
state
|
||||
.pending_timeouts
|
||||
.insert(timeout_id, ScheduledTimeout { callback });
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(delay_ms));
|
||||
let _ = runtime_command_tx.send(RuntimeCommand::TimeoutFired { id: timeout_id });
|
||||
});
|
||||
|
||||
Ok(timeout_id)
|
||||
}
|
||||
|
||||
pub(super) fn clear_timeout(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
) -> Result<(), String> {
|
||||
let Some(timeout_id) = timeout_id_from_args(scope, args)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
|
||||
return Err("runtime state unavailable".to_string());
|
||||
};
|
||||
state.pending_timeouts.remove(&timeout_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn invoke_timeout_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
timeout_id: u64,
|
||||
) -> Result<(), String> {
|
||||
let callback = {
|
||||
let state = scope
|
||||
.get_slot_mut::<RuntimeState>()
|
||||
.ok_or_else(|| "runtime state unavailable".to_string())?;
|
||||
state.pending_timeouts.remove(&timeout_id)
|
||||
};
|
||||
let Some(callback) = callback else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let callback = v8::Local::new(&tc, &callback.callback);
|
||||
let receiver = v8::undefined(&tc).into();
|
||||
let _ = callback.call(&tc, receiver, &[]);
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn timeout_id_from_args(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
) -> Result<Option<u64>, String> {
|
||||
if args.length() == 0 || args.get(0).is_null_or_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(timeout_id) = args.get(0).number_value(scope) else {
|
||||
return Err("clearTimeout expects a numeric timeout id".to_string());
|
||||
};
|
||||
if !timeout_id.is_finite() || timeout_id <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(timeout_id.trunc().min(u64::MAX as f64) as u64))
|
||||
}
|
||||
|
||||
fn normalize_delay_ms(delay_ms: f64) -> u64 {
|
||||
if !delay_ms.is_finite() || delay_ms <= 0.0 {
|
||||
0
|
||||
} else {
|
||||
delay_ms.trunc().min(u64::MAX as f64) as u64
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use codex_code_mode_protocol::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::ImageDetail;
|
||||
|
||||
const IMAGE_HELPER_EXPECTS_MESSAGE: &str = "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block";
|
||||
const AUDIO_HELPER_EXPECTS_MESSAGE: &str = "audio expects a non-empty audio URL string, an object with audio_url, or a raw MCP audio block";
|
||||
const REMOTE_IMAGE_URL_ERROR: &str = "Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead";
|
||||
const INVALID_IMAGE_URL_ERROR: &str =
|
||||
"Tool call failed: invalid image output. Pass a base64 data URI instead";
|
||||
const INVALID_AUDIO_URL_ERROR: &str =
|
||||
"Tool call failed: invalid audio output. Pass a base64 data URI instead";
|
||||
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
|
||||
|
||||
pub(super) fn serialize_output_text(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<String, String> {
|
||||
if value.is_undefined()
|
||||
|| value.is_null()
|
||||
|| value.is_boolean()
|
||||
|| value.is_number()
|
||||
|| value.is_big_int()
|
||||
|| value.is_string()
|
||||
{
|
||||
return Ok(value.to_rust_string_lossy(scope));
|
||||
}
|
||||
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
if let Some(stringified) = v8::json::stringify(&tc, value) {
|
||||
return Ok(stringified.to_rust_string_lossy(&tc));
|
||||
}
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
Ok(value.to_rust_string_lossy(&tc))
|
||||
}
|
||||
|
||||
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> {
|
||||
let (image_url, detail) = if value.is_string() {
|
||||
(value.to_rust_string_lossy(scope), None)
|
||||
} else if value.is_object() && !value.is_array() {
|
||||
let object = v8::Local::<v8::Object>::try_from(value)
|
||||
.map_err(|_| IMAGE_HELPER_EXPECTS_MESSAGE.to_string())?;
|
||||
if let Some(image) = parse_non_mcp_output_image(scope, object)? {
|
||||
image
|
||||
} else {
|
||||
parse_mcp_output_image(scope, value)?
|
||||
}
|
||||
} else {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
|
||||
if image_url.is_empty() {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
}
|
||||
let Some((scheme, _)) = image_url.split_once(':') else {
|
||||
return Err(INVALID_IMAGE_URL_ERROR.to_string());
|
||||
};
|
||||
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
|
||||
return Err(REMOTE_IMAGE_URL_ERROR.to_string());
|
||||
}
|
||||
if !scheme.eq_ignore_ascii_case("data") {
|
||||
return Err(INVALID_IMAGE_URL_ERROR.to_string());
|
||||
}
|
||||
|
||||
let detail = detail_override.or(detail);
|
||||
let detail = match detail {
|
||||
Some(detail) => {
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
Some(match normalized.as_str() {
|
||||
"auto" => ImageDetail::Auto,
|
||||
"low" => ImageDetail::Low,
|
||||
"high" => ImageDetail::High,
|
||||
"original" => ImageDetail::Original,
|
||||
_ => {
|
||||
return Err(
|
||||
"image detail must be one of: auto, low, high, original".to_string()
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
None => Some(DEFAULT_IMAGE_DETAIL),
|
||||
};
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputImage { image_url, detail })
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(item) => Ok(item),
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_non_mcp_output_image(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
object: v8::Local<'_, v8::Object>,
|
||||
) -> Result<Option<(String, Option<String>)>, String> {
|
||||
let image_url_key = v8::String::new(scope, "image_url")
|
||||
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
|
||||
let Some(image_url) = object.get(scope, image_url_key.into()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if image_url.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !image_url.is_string() {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
}
|
||||
let detail_key = v8::String::new(scope, "detail")
|
||||
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
|
||||
let detail = parse_image_detail_value(scope, object.get(scope, detail_key.into()))?;
|
||||
Ok(Some((image_url.to_rust_string_lossy(scope), detail)))
|
||||
}
|
||||
|
||||
fn parse_mcp_output_image(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
let Some(result) = v8_value_to_json(scope, value)? else {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
let JsonValue::Object(result) = result else {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
let Some(item_type) = result.get("type").and_then(JsonValue::as_str) else {
|
||||
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
if item_type != "image" {
|
||||
return Err(format!(
|
||||
"image only accepts MCP image blocks, got \"{item_type}\""
|
||||
));
|
||||
}
|
||||
let data = result
|
||||
.get("data")
|
||||
.and_then(JsonValue::as_str)
|
||||
.ok_or_else(|| "image expected MCP image data".to_string())?;
|
||||
if data.is_empty() {
|
||||
return Err("image expected MCP image data".to_string());
|
||||
}
|
||||
|
||||
let image_url = if data.to_ascii_lowercase().starts_with("data:") {
|
||||
data.to_string()
|
||||
} else {
|
||||
let mime_type = result
|
||||
.get("mimeType")
|
||||
.or_else(|| result.get("mime_type"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|mime_type| !mime_type.is_empty())
|
||||
.unwrap_or("application/octet-stream");
|
||||
format!("data:{mime_type};base64,{data}")
|
||||
};
|
||||
let detail = result
|
||||
.get("_meta")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original"))
|
||||
.map(str::to_string);
|
||||
Ok((image_url, detail))
|
||||
}
|
||||
|
||||
fn parse_image_detail_value<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
value: Option<v8::Local<'s, v8::Value>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
match value {
|
||||
Some(value) if value.is_string() => Ok(Some(value.to_rust_string_lossy(scope))),
|
||||
Some(value) if value.is_null() || value.is_undefined() => Ok(None),
|
||||
Some(_) => Err("image detail must be a string when provided".to_string()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_output_audio(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<FunctionCallOutputContentItem, ()> {
|
||||
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
|
||||
let audio_url = if value.is_string() {
|
||||
value.to_rust_string_lossy(scope)
|
||||
} else if value.is_object() && !value.is_array() {
|
||||
let object = v8::Local::<v8::Object>::try_from(value)
|
||||
.map_err(|_| AUDIO_HELPER_EXPECTS_MESSAGE.to_string())?;
|
||||
if let Some(audio_url) = parse_non_mcp_output_audio(scope, object)? {
|
||||
audio_url
|
||||
} else {
|
||||
parse_mcp_output_audio(scope, value)?
|
||||
}
|
||||
} else {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
|
||||
if audio_url.is_empty() {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
}
|
||||
let Some((scheme, _)) = audio_url.split_once(':') else {
|
||||
return Err(INVALID_AUDIO_URL_ERROR.to_string());
|
||||
};
|
||||
if !scheme.eq_ignore_ascii_case("data") {
|
||||
return Err(INVALID_AUDIO_URL_ERROR.to_string());
|
||||
}
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputAudio { audio_url })
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(item) => Ok(item),
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_non_mcp_output_audio(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
object: v8::Local<'_, v8::Object>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let audio_url_key = v8::String::new(scope, "audio_url")
|
||||
.ok_or_else(|| "failed to allocate audio helper keys".to_string())?;
|
||||
let Some(audio_url) = object.get(scope, audio_url_key.into()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if audio_url.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !audio_url.is_string() {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
}
|
||||
Ok(Some(audio_url.to_rust_string_lossy(scope)))
|
||||
}
|
||||
|
||||
fn parse_mcp_output_audio(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<String, String> {
|
||||
let Some(result) = v8_value_to_json(scope, value)? else {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
let JsonValue::Object(result) = result else {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
let Some(item_type) = result.get("type").and_then(JsonValue::as_str) else {
|
||||
return Err(AUDIO_HELPER_EXPECTS_MESSAGE.to_string());
|
||||
};
|
||||
if item_type != "audio" {
|
||||
return Err(format!(
|
||||
"audio only accepts MCP audio blocks, got \"{item_type}\""
|
||||
));
|
||||
}
|
||||
let data = result
|
||||
.get("data")
|
||||
.and_then(JsonValue::as_str)
|
||||
.ok_or_else(|| "audio expected MCP audio data".to_string())?;
|
||||
if data.is_empty() {
|
||||
return Err("audio expected MCP audio data".to_string());
|
||||
}
|
||||
|
||||
if data.to_ascii_lowercase().starts_with("data:") {
|
||||
Ok(data.to_string())
|
||||
} else {
|
||||
let mime_type = result
|
||||
.get("mimeType")
|
||||
.or_else(|| result.get("mime_type"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|mime_type| !mime_type.is_empty())
|
||||
.unwrap_or("application/octet-stream");
|
||||
Ok(format!("data:{mime_type};base64,{data}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn v8_value_to_json(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let Some(stringified) = v8::json::stringify(&tc, value) else {
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_str(&stringified.to_rust_string_lossy(&tc))
|
||||
.map(Some)
|
||||
.map_err(|err| format!("failed to serialize JavaScript value: {err}"))
|
||||
}
|
||||
|
||||
pub(super) fn json_to_v8<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
value: &JsonValue,
|
||||
) -> Option<v8::Local<'s, v8::Value>> {
|
||||
let json = serde_json::to_string(value).ok()?;
|
||||
let json = v8::String::new(scope, &json)?;
|
||||
v8::json::parse(scope, json)
|
||||
}
|
||||
|
||||
pub(super) fn value_to_error_text(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> String {
|
||||
if value.is_object()
|
||||
&& let Ok(object) = v8::Local::<v8::Object>::try_from(value)
|
||||
&& let Some(key) = v8::String::new(scope, "stack")
|
||||
&& let Some(stack) = object.get(scope, key.into())
|
||||
&& stack.is_string()
|
||||
{
|
||||
return stack.to_rust_string_lossy(scope);
|
||||
}
|
||||
value.to_rust_string_lossy(scope)
|
||||
}
|
||||
|
||||
pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) {
|
||||
if let Some(message) = v8::String::new(scope, message) {
|
||||
scope.throw_exception(message.into());
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_code_mode_protocol::CellId;
|
||||
use codex_code_mode_protocol::CodeModeNestedToolCall;
|
||||
use codex_code_mode_protocol::CodeModeSession;
|
||||
use codex_code_mode_protocol::CodeModeSessionDelegate;
|
||||
use codex_code_mode_protocol::CodeModeSessionProvider;
|
||||
use codex_code_mode_protocol::CodeModeSessionProviderFuture;
|
||||
use codex_code_mode_protocol::CodeModeSessionResultFuture;
|
||||
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;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::ImageDetail;
|
||||
use codex_code_mode_protocol::NotificationFuture;
|
||||
use codex_code_mode_protocol::RuntimeResponse;
|
||||
use codex_code_mode_protocol::StartedCell;
|
||||
use codex_code_mode_protocol::ToolInvocationFuture;
|
||||
use codex_code_mode_protocol::WaitOutcome;
|
||||
use codex_code_mode_protocol::WaitRequest;
|
||||
use codex_code_mode_protocol::WaitToPendingOutcome;
|
||||
use codex_code_mode_protocol::WaitToPendingRequest;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::session_runtime as runtime;
|
||||
use crate::session_runtime::SessionRuntime;
|
||||
|
||||
const YIELD_GRACE_PERIOD: Duration = Duration::from_secs(1);
|
||||
const MIN_YIELD_TIME_FOR_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
fn yield_timeout(yield_time_ms: u64) -> Duration {
|
||||
let yield_time = Duration::from_millis(yield_time_ms);
|
||||
if yield_time >= MIN_YIELD_TIME_FOR_GRACE {
|
||||
yield_time.saturating_add(YIELD_GRACE_PERIOD)
|
||||
} else {
|
||||
yield_time
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopCodeModeSessionDelegate;
|
||||
|
||||
impl CodeModeSessionDelegate for NoopCodeModeSessionDelegate {
|
||||
fn invoke_tool<'a>(
|
||||
&'a self,
|
||||
_invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> ToolInvocationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
cancellation_token.cancelled().await;
|
||||
Err("code mode nested tools are unavailable".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn notify<'a>(
|
||||
&'a self,
|
||||
_call_id: String,
|
||||
_cell_id: CellId,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> NotificationFuture<'a> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn cell_closed(&self, _cell_id: &CellId) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InProcessCodeModeSessionProvider;
|
||||
|
||||
impl CodeModeSessionProvider for InProcessCodeModeSessionProvider {
|
||||
fn create_session<'a>(
|
||||
&'a self,
|
||||
delegate: Arc<dyn CodeModeSessionDelegate>,
|
||||
) -> CodeModeSessionProviderFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let session: Arc<dyn CodeModeSession> =
|
||||
Arc::new(InProcessCodeModeSession::with_delegate(delegate));
|
||||
Ok(session)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InProcessCodeModeSession {
|
||||
runtime: SessionRuntime<ProtocolDelegate>,
|
||||
}
|
||||
|
||||
impl InProcessCodeModeSession {
|
||||
pub fn new() -> Self {
|
||||
Self::with_delegate(Arc::new(NoopCodeModeSessionDelegate))
|
||||
}
|
||||
|
||||
pub fn with_delegate(delegate: Arc<dyn CodeModeSessionDelegate>) -> Self {
|
||||
Self {
|
||||
runtime: SessionRuntime::new(Arc::new(ProtocolDelegate { delegate })),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_delegate_and_task_failure_handler(
|
||||
delegate: Arc<dyn CodeModeSessionDelegate>,
|
||||
task_failure_handler: Arc<dyn Fn(String) + Send + Sync>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime: SessionRuntime::new_with_task_failure_handler(
|
||||
Arc::new(ProtocolDelegate { delegate }),
|
||||
Some(task_failure_handler),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, request: ExecuteRequest) -> Result<StartedCell, String> {
|
||||
let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS);
|
||||
let started = self
|
||||
.runtime
|
||||
.execute(
|
||||
runtime_request(request),
|
||||
runtime::ObserveMode::YieldAfter(yield_timeout(yield_time_ms)),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let cell_id = protocol_cell_id(&started.cell_id);
|
||||
let response_cell_id = cell_id.clone();
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let response = started
|
||||
.initial_event()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|event| runtime_response(&response_cell_id, event));
|
||||
let _ = response_tx.send(response);
|
||||
});
|
||||
Ok(StartedCell::from_result_receiver(cell_id, response_rx))
|
||||
}
|
||||
|
||||
pub async fn execute_to_pending(
|
||||
&self,
|
||||
request: ExecuteRequest,
|
||||
) -> Result<ExecuteToPendingOutcome, String> {
|
||||
let started = self
|
||||
.runtime
|
||||
.execute(
|
||||
runtime_request(request),
|
||||
runtime::ObserveMode::PendingFrontier,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let cell_id = protocol_cell_id(&started.cell_id);
|
||||
let event = started
|
||||
.initial_event()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
pending_outcome(&cell_id, event)
|
||||
}
|
||||
|
||||
pub async fn wait(&self, request: WaitRequest) -> Result<WaitOutcome, String> {
|
||||
self.begin_wait(request).await.await
|
||||
}
|
||||
|
||||
async fn begin_wait(
|
||||
&self,
|
||||
request: WaitRequest,
|
||||
) -> CodeModeSessionResultFuture<'static, WaitOutcome> {
|
||||
let WaitRequest {
|
||||
cell_id,
|
||||
yield_time_ms,
|
||||
} = request;
|
||||
let runtime_cell_id = runtime_cell_id(&cell_id);
|
||||
match self
|
||||
.runtime
|
||||
.begin_observe(
|
||||
&runtime_cell_id,
|
||||
runtime::ObserveMode::YieldAfter(yield_timeout(yield_time_ms)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pending_event) => Box::pin(async move {
|
||||
match pending_event.event().await {
|
||||
Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)),
|
||||
Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => {
|
||||
Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id)))
|
||||
}
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}),
|
||||
Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => {
|
||||
missing_wait(cell_id)
|
||||
}
|
||||
Err(error) => Box::pin(async move { Err(error.to_string()) }),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn terminate(&self, cell_id: CellId) -> Result<WaitOutcome, String> {
|
||||
match self.runtime.terminate(&runtime_cell_id(&cell_id)).await {
|
||||
Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)),
|
||||
Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => {
|
||||
Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id)))
|
||||
}
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_to_pending(
|
||||
&self,
|
||||
request: WaitToPendingRequest,
|
||||
) -> Result<WaitToPendingOutcome, String> {
|
||||
let cell_id = request.cell_id;
|
||||
match self
|
||||
.runtime
|
||||
.observe(
|
||||
&runtime_cell_id(&cell_id),
|
||||
runtime::ObserveMode::PendingFrontier,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome(
|
||||
&cell_id, event,
|
||||
)?)),
|
||||
Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => Ok(
|
||||
WaitToPendingOutcome::MissingCell(missing_cell_response(cell_id)),
|
||||
),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> Result<(), String> {
|
||||
self.runtime
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InProcessCodeModeSession {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeSession for InProcessCodeModeSession {
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
request: ExecuteRequest,
|
||||
) -> CodeModeSessionResultFuture<'a, StartedCell> {
|
||||
Box::pin(InProcessCodeModeSession::execute(self, request))
|
||||
}
|
||||
|
||||
fn wait<'a>(&'a self, request: WaitRequest) -> CodeModeSessionResultFuture<'a, WaitOutcome> {
|
||||
Box::pin(InProcessCodeModeSession::wait(self, request))
|
||||
}
|
||||
|
||||
fn terminate<'a>(&'a self, cell_id: CellId) -> CodeModeSessionResultFuture<'a, WaitOutcome> {
|
||||
Box::pin(InProcessCodeModeSession::terminate(self, cell_id))
|
||||
}
|
||||
|
||||
fn shutdown<'a>(&'a self) -> CodeModeSessionResultFuture<'a, ()> {
|
||||
Box::pin(InProcessCodeModeSession::shutdown(self))
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolDelegate {
|
||||
delegate: Arc<dyn CodeModeSessionDelegate>,
|
||||
}
|
||||
|
||||
impl runtime::SessionRuntimeDelegate for ProtocolDelegate {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
invocation: runtime::NestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
self.delegate
|
||||
.invoke_tool(
|
||||
CodeModeNestedToolCall {
|
||||
cell_id: protocol_cell_id(&invocation.cell_id),
|
||||
runtime_tool_call_id: invocation.runtime_tool_call_id,
|
||||
tool_name: codex_protocol::ToolName {
|
||||
name: invocation.tool_name.name,
|
||||
namespace: invocation.tool_name.namespace,
|
||||
},
|
||||
tool_kind: match invocation.tool_kind {
|
||||
runtime::ToolKind::Function => CodeModeToolKind::Function,
|
||||
runtime::ToolKind::Freeform => CodeModeToolKind::Freeform,
|
||||
},
|
||||
input: invocation.input,
|
||||
},
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
call_id: String,
|
||||
cell_id: runtime::CellId,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
self.delegate
|
||||
.notify(
|
||||
call_id,
|
||||
protocol_cell_id(&cell_id),
|
||||
text,
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn cell_closed(&self, cell_id: &runtime::CellId) {
|
||||
self.delegate.cell_closed(&protocol_cell_id(cell_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_request(request: ExecuteRequest) -> runtime::CreateCellRequest {
|
||||
runtime::CreateCellRequest {
|
||||
tool_call_id: request.tool_call_id,
|
||||
enabled_tools: request
|
||||
.enabled_tools
|
||||
.into_iter()
|
||||
.map(|definition| runtime::ToolDefinition {
|
||||
name: definition.name,
|
||||
tool_name: runtime::ToolName {
|
||||
name: definition.tool_name.name,
|
||||
namespace: definition.tool_name.namespace,
|
||||
},
|
||||
description: definition.description,
|
||||
kind: match definition.kind {
|
||||
CodeModeToolKind::Function => runtime::ToolKind::Function,
|
||||
CodeModeToolKind::Freeform => runtime::ToolKind::Freeform,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
source: request.source,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_cell_id(cell_id: &CellId) -> runtime::CellId {
|
||||
runtime::CellId::new(cell_id.as_str())
|
||||
}
|
||||
|
||||
fn protocol_cell_id(cell_id: &runtime::CellId) -> CellId {
|
||||
CellId::new(cell_id.as_str().to_string())
|
||||
}
|
||||
|
||||
fn pending_outcome(
|
||||
cell_id: &CellId,
|
||||
event: runtime::CellEvent,
|
||||
) -> Result<ExecuteToPendingOutcome, String> {
|
||||
match event {
|
||||
runtime::CellEvent::Pending {
|
||||
content_items,
|
||||
pending_tool_call_ids,
|
||||
} => Ok(ExecuteToPendingOutcome::Pending {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
pending_tool_call_ids,
|
||||
}),
|
||||
event => Ok(ExecuteToPendingOutcome::Completed(runtime_response(
|
||||
cell_id, event,
|
||||
)?)),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_response(
|
||||
cell_id: &CellId,
|
||||
event: runtime::CellEvent,
|
||||
) -> Result<RuntimeResponse, String> {
|
||||
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(),
|
||||
}),
|
||||
runtime::CellEvent::Completed {
|
||||
content_items,
|
||||
error_text,
|
||||
} => Ok(RuntimeResponse::Result {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
error_text,
|
||||
}),
|
||||
runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
}),
|
||||
runtime::CellEvent::Pending { .. } => {
|
||||
Err("cell returned a pending frontier unexpectedly".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")),
|
||||
cell_id,
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_wait(cell_id: CellId) -> CodeModeSessionResultFuture<'static, WaitOutcome> {
|
||||
Box::pin(async move { Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_contract_tests.rs"]
|
||||
mod contract_tests;
|
||||
@@ -1,530 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::ToolName;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::CodeModeToolKind;
|
||||
use crate::ToolDefinition;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum DelegateEvent {
|
||||
NotificationStarted,
|
||||
NotificationCancelled,
|
||||
ToolStarted,
|
||||
ToolCancelled,
|
||||
CellClosed(CellId),
|
||||
}
|
||||
|
||||
struct BlockingDelegate {
|
||||
events_tx: mpsc::UnboundedSender<DelegateEvent>,
|
||||
notification_finished: AtomicBool,
|
||||
tool_finished: AtomicBool,
|
||||
tool_release: Notify,
|
||||
}
|
||||
|
||||
struct HeldNotificationDelegate {
|
||||
events_tx: mpsc::UnboundedSender<DelegateEvent>,
|
||||
notification_release: Notify,
|
||||
}
|
||||
|
||||
impl HeldNotificationDelegate {
|
||||
fn new() -> (Arc<Self>, mpsc::UnboundedReceiver<DelegateEvent>) {
|
||||
let (events_tx, events_rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Arc::new(Self {
|
||||
events_tx,
|
||||
notification_release: Notify::new(),
|
||||
}),
|
||||
events_rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn release_notification(&self) {
|
||||
self.notification_release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeSessionDelegate for HeldNotificationDelegate {
|
||||
fn invoke_tool<'a>(
|
||||
&'a self,
|
||||
_invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> ToolInvocationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
cancellation_token.cancelled().await;
|
||||
Err("cancelled".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn notify<'a>(
|
||||
&'a self,
|
||||
_call_id: String,
|
||||
_cell_id: CellId,
|
||||
_text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> NotificationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let _ = self.events_tx.send(DelegateEvent::NotificationStarted);
|
||||
cancellation_token.cancelled().await;
|
||||
let _ = self.events_tx.send(DelegateEvent::NotificationCancelled);
|
||||
self.notification_release.notified().await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_closed(&self, cell_id: &CellId) {
|
||||
let _ = self
|
||||
.events_tx
|
||||
.send(DelegateEvent::CellClosed(cell_id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockingDelegate {
|
||||
fn new() -> (Arc<Self>, mpsc::UnboundedReceiver<DelegateEvent>) {
|
||||
let (events_tx, events_rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Arc::new(Self {
|
||||
events_tx,
|
||||
notification_finished: AtomicBool::new(false),
|
||||
tool_finished: AtomicBool::new(false),
|
||||
tool_release: Notify::new(),
|
||||
}),
|
||||
events_rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn release_tool(&self) {
|
||||
self.tool_release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeSessionDelegate for BlockingDelegate {
|
||||
fn invoke_tool<'a>(
|
||||
&'a self,
|
||||
_invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> ToolInvocationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let _ = self.events_tx.send(DelegateEvent::ToolStarted);
|
||||
tokio::select! {
|
||||
_ = self.tool_release.notified() => {
|
||||
self.tool_finished.store(true, Ordering::Release);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
_ = cancellation_token.cancelled() => {
|
||||
self.tool_finished.store(true, Ordering::Release);
|
||||
let _ = self.events_tx.send(DelegateEvent::ToolCancelled);
|
||||
Err("cancelled".to_string())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn notify<'a>(
|
||||
&'a self,
|
||||
_call_id: String,
|
||||
_cell_id: CellId,
|
||||
_text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> NotificationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let _ = self.events_tx.send(DelegateEvent::NotificationStarted);
|
||||
cancellation_token.cancelled().await;
|
||||
self.notification_finished.store(true, Ordering::Release);
|
||||
let _ = self.events_tx.send(DelegateEvent::NotificationCancelled);
|
||||
Err("cancelled".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_closed(&self, cell_id: &CellId) {
|
||||
let _ = self
|
||||
.events_tx
|
||||
.send(DelegateEvent::CellClosed(cell_id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_id(value: &str) -> CellId {
|
||||
CellId::new(value.to_string())
|
||||
}
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
tool_call_id: "call-1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
yield_time_ms: Some(1),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn blocking_tool() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: "block".to_string(),
|
||||
tool_name: ToolName::plain("block"),
|
||||
description: String::new(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_event(events_rx: &mut mpsc::UnboundedReceiver<DelegateEvent>) -> DelegateEvent {
|
||||
tokio::time::timeout(Duration::from_secs(2), events_rx.recv())
|
||||
.await
|
||||
.expect("delegate event timeout")
|
||||
.expect("delegate event channel closed")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yields_and_resumes() {
|
||||
let service = InProcessCodeModeSession::new();
|
||||
let cell = service
|
||||
.execute(ExecuteRequest {
|
||||
source: r#"text("before"); yield_control(); text("after");"#.to_string(),
|
||||
yield_time_ms: Some(60_000),
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.wait(WaitRequest {
|
||||
cell_id: cell_id("1"),
|
||||
yield_time_ms: 60_000,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
WaitOutcome::LiveCell(RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "after".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_and_resumes_from_the_pending_frontier() {
|
||||
let (delegate, mut events_rx) = BlockingDelegate::new();
|
||||
let service = InProcessCodeModeSession::with_delegate(delegate.clone());
|
||||
|
||||
assert_eq!(
|
||||
service
|
||||
.execute_to_pending(ExecuteRequest {
|
||||
enabled_tools: vec![blocking_tool()],
|
||||
source: r#"
|
||||
await tools.block({});
|
||||
text("after");
|
||||
"#
|
||||
.to_string(),
|
||||
yield_time_ms: Some(60_000),
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
ExecuteToPendingOutcome::Pending {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
pending_tool_call_ids: vec!["tool-1".to_string()],
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted);
|
||||
delegate.release_tool();
|
||||
|
||||
assert_eq!(
|
||||
service
|
||||
.wait_to_pending(WaitToPendingRequest {
|
||||
cell_id: cell_id("1"),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed(
|
||||
RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "after".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observed_natural_completion_wins_over_termination() {
|
||||
let service = InProcessCodeModeSession::new();
|
||||
let cell = service
|
||||
.execute(execute_request(
|
||||
r#"yield_control(); store("finished", true); text("done");"#,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let response = service
|
||||
.execute(ExecuteRequest {
|
||||
yield_time_ms: Some(60_000),
|
||||
..execute_request(r#"text(String(load("finished")));"#)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.initial_response()
|
||||
.await
|
||||
.unwrap();
|
||||
let RuntimeResponse::Result { content_items, .. } = response else {
|
||||
panic!("expected stored-value probe to complete");
|
||||
};
|
||||
if content_items
|
||||
== vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "true".to_string(),
|
||||
}]
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
service.terminate(cell_id("1")).await.unwrap(),
|
||||
WaitOutcome::LiveCell(RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn termination_cancels_pending_callbacks_before_responding() {
|
||||
let (delegate, mut events_rx) = BlockingDelegate::new();
|
||||
let service = InProcessCodeModeSession::with_delegate(delegate.clone());
|
||||
let cell = service
|
||||
.execute(execute_request(
|
||||
r#"notify("pending"); await new Promise(() => {});"#,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationStarted
|
||||
);
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
service.terminate(cell_id("1")).await.unwrap(),
|
||||
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
assert!(delegate.notification_finished.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationCancelled
|
||||
);
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::CellClosed(cell_id("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_cancels_notifications_while_natural_completion_is_draining() {
|
||||
let (delegate, mut events_rx) = HeldNotificationDelegate::new();
|
||||
let service = Arc::new(InProcessCodeModeSession::with_delegate(delegate.clone()));
|
||||
service
|
||||
.execute(execute_request(r#"notify("pending");"#))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationStarted
|
||||
);
|
||||
|
||||
let shutdown_service = Arc::clone(&service);
|
||||
let shutdown = tokio::spawn(async move { shutdown_service.shutdown().await });
|
||||
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationCancelled
|
||||
);
|
||||
delegate.release_notification();
|
||||
|
||||
assert_eq!(shutdown.await.unwrap(), Ok(()));
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::CellClosed(cell_id("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_termination_is_rejected_while_callback_cleanup_is_pending() {
|
||||
let (delegate, mut events_rx) = HeldNotificationDelegate::new();
|
||||
let service = Arc::new(InProcessCodeModeSession::with_delegate(delegate.clone()));
|
||||
let cell = service
|
||||
.execute(execute_request(
|
||||
r#"notify("pending"); await new Promise(() => {});"#,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationStarted
|
||||
);
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let terminating_service = Arc::clone(&service);
|
||||
let first_termination =
|
||||
tokio::spawn(async move { terminating_service.terminate(cell_id("1")).await });
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::NotificationCancelled
|
||||
);
|
||||
|
||||
let repeated_termination = service.terminate(cell_id("1")).await;
|
||||
delegate.release_notification();
|
||||
|
||||
assert_eq!(
|
||||
repeated_termination.unwrap_err(),
|
||||
"exec cell 1 is already terminating"
|
||||
);
|
||||
assert_eq!(
|
||||
first_termination.await.unwrap().unwrap(),
|
||||
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::CellClosed(cell_id("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_observer_is_rejected_without_displacing_the_first() {
|
||||
let service = InProcessCodeModeSession::new();
|
||||
let cell = service
|
||||
.execute(execute_request("await new Promise(() => {});"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let first_observer = service
|
||||
.begin_wait(WaitRequest {
|
||||
cell_id: cell_id("1"),
|
||||
yield_time_ms: 60_000,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
service
|
||||
.wait(WaitRequest {
|
||||
cell_id: cell_id("1"),
|
||||
yield_time_ms: 60_000,
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
"exec cell 1 already has an active observer"
|
||||
);
|
||||
|
||||
let terminated = RuntimeResponse::Terminated {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
service.terminate(cell_id("1")).await.unwrap(),
|
||||
WaitOutcome::LiveCell(terminated.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
first_observer.await.unwrap(),
|
||||
WaitOutcome::LiveCell(terminated)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn natural_completion_cleans_up_callbacks_before_responding() {
|
||||
let (delegate, mut events_rx) = BlockingDelegate::new();
|
||||
let service = InProcessCodeModeSession::with_delegate(delegate.clone());
|
||||
let cell = service
|
||||
.execute(ExecuteRequest {
|
||||
enabled_tools: vec![blocking_tool()],
|
||||
source: r#"tools.block({}); text("done");"#.to_string(),
|
||||
yield_time_ms: Some(60_000),
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted);
|
||||
assert_eq!(
|
||||
cell.initial_response().await.unwrap(),
|
||||
RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
assert!(delegate.tool_finished.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::ToolCancelled
|
||||
);
|
||||
assert_eq!(
|
||||
next_event(&mut events_rx).await,
|
||||
DelegateEvent::CellClosed(cell_id("1"))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
mod types;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
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::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;
|
||||
pub(crate) use self::types::ToolName;
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::cell_actor::CellActor;
|
||||
use crate::cell_actor::CellError;
|
||||
use crate::cell_actor::CellEventFuture;
|
||||
use crate::cell_actor::CellHandle;
|
||||
use crate::cell_actor::CellHost;
|
||||
use crate::cell_actor::CellState;
|
||||
use crate::cell_actor::CellToolCall;
|
||||
use crate::cell_actor::CompletionCommit;
|
||||
|
||||
type RuntimeEventFuture = Pin<Box<dyn Future<Output = Result<CellEvent, Error>> + Send + 'static>>;
|
||||
|
||||
/// Owns all cells and shared state for one transport-neutral code-mode session.
|
||||
pub(crate) struct SessionRuntime<D: SessionRuntimeDelegate> {
|
||||
inner: Arc<Inner<D>>,
|
||||
}
|
||||
|
||||
struct Inner<D: SessionRuntimeDelegate> {
|
||||
stored_values: Mutex<HashMap<String, JsonValue>>,
|
||||
cells: Mutex<HashMap<CellId, CellHandle>>,
|
||||
cell_tasks: TaskTracker,
|
||||
shutdown_token: CancellationToken,
|
||||
delegate: Arc<D>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
next_cell_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl<D: SessionRuntimeDelegate> SessionRuntime<D> {
|
||||
pub(crate) fn new(delegate: Arc<D>) -> Self {
|
||||
Self::new_with_task_failure_handler(delegate, /*task_failure_handler*/ None)
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_task_failure_handler(
|
||||
delegate: Arc<D>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
cells: Mutex::new(HashMap::new()),
|
||||
cell_tasks: TaskTracker::new(),
|
||||
shutdown_token: CancellationToken::new(),
|
||||
delegate,
|
||||
task_failure_handler,
|
||||
next_cell_id: AtomicU64::new(1),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
&self,
|
||||
request: CreateCellRequest,
|
||||
initial_observe_mode: ObserveMode,
|
||||
) -> Result<StartedCell, Error> {
|
||||
if self.inner.shutdown_token.is_cancelled() {
|
||||
return Err(Error::ShuttingDown);
|
||||
}
|
||||
let cell_id = self.allocate_cell_id()?;
|
||||
let initial_event = self
|
||||
.start_cell(cell_id.clone(), request, initial_observe_mode)
|
||||
.await?;
|
||||
Ok(StartedCell {
|
||||
cell_id,
|
||||
initial_event,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn observe(
|
||||
&self,
|
||||
cell_id: &CellId,
|
||||
mode: ObserveMode,
|
||||
) -> Result<CellEvent, Error> {
|
||||
self.begin_observe(cell_id, mode).await?.event().await
|
||||
}
|
||||
|
||||
pub(crate) async fn begin_observe(
|
||||
&self,
|
||||
cell_id: &CellId,
|
||||
mode: ObserveMode,
|
||||
) -> Result<PendingEvent, Error> {
|
||||
let handle = self
|
||||
.inner
|
||||
.cells
|
||||
.lock()
|
||||
.await
|
||||
.get(cell_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::MissingCell(cell_id.clone()))?;
|
||||
Ok(PendingEvent {
|
||||
event: map_actor_event(cell_id.clone(), handle.observe(mode)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn terminate(&self, cell_id: &CellId) -> Result<CellEvent, Error> {
|
||||
let handle = self
|
||||
.inner
|
||||
.cells
|
||||
.lock()
|
||||
.await
|
||||
.get(cell_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::MissingCell(cell_id.clone()))?;
|
||||
handle
|
||||
.terminate()
|
||||
.await
|
||||
.map_err(|error| actor_error(cell_id, error))
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) -> Result<(), Error> {
|
||||
self.begin_shutdown();
|
||||
// Taking the registry lock ensures every cell that passed the shutdown
|
||||
// check has registered its actor with the tracker before we wait.
|
||||
let cells = self.inner.cells.lock().await;
|
||||
self.inner.cell_tasks.close();
|
||||
drop(cells);
|
||||
self.inner.cell_tasks.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn allocate_cell_id(&self) -> Result<CellId, Error> {
|
||||
self.inner
|
||||
.next_cell_id
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next_cell_id| {
|
||||
next_cell_id.checked_add(1)
|
||||
})
|
||||
.map(|cell_id| CellId::new(cell_id.to_string()))
|
||||
.map_err(|_| Error::CellIdSpaceExhausted)
|
||||
}
|
||||
|
||||
async fn start_cell(
|
||||
&self,
|
||||
cell_id: CellId,
|
||||
request: CreateCellRequest,
|
||||
initial_observe_mode: ObserveMode,
|
||||
) -> Result<RuntimeEventFuture, Error> {
|
||||
let stored_values = self.inner.stored_values.lock().await.clone();
|
||||
let host = Arc::new(RuntimeCellHost {
|
||||
cell_id: cell_id.clone(),
|
||||
inner: Arc::clone(&self.inner),
|
||||
});
|
||||
let mut cells = self.inner.cells.lock().await;
|
||||
if self.inner.shutdown_token.is_cancelled() {
|
||||
return Err(Error::ShuttingDown);
|
||||
}
|
||||
if cells.contains_key(&cell_id) {
|
||||
return Err(Error::DuplicateCell(cell_id));
|
||||
}
|
||||
let cell_state = Arc::new(CellState::new(self.inner.shutdown_token.child_token()));
|
||||
let (handle, initial_event, task) = CellActor::prepare(
|
||||
request,
|
||||
stored_values,
|
||||
host,
|
||||
initial_observe_mode,
|
||||
cell_state,
|
||||
self.inner.task_failure_handler.clone(),
|
||||
)
|
||||
.map_err(Error::Runtime)?;
|
||||
cells.insert(cell_id.clone(), handle);
|
||||
let task = self.inner.cell_tasks.spawn(task);
|
||||
if let Some(task_failure_handler) = self.inner.task_failure_handler.clone() {
|
||||
let failed_cell_id = cell_id.clone();
|
||||
let _failure_watcher = self.inner.cell_tasks.spawn(async move {
|
||||
if let Err(err) = task.await {
|
||||
task_failure_handler(format!(
|
||||
"code-mode cell {failed_cell_id} task failed: {err}"
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(cells);
|
||||
Ok(map_actor_event(cell_id, initial_event))
|
||||
}
|
||||
|
||||
fn begin_shutdown(&self) {
|
||||
self.inner.shutdown_token.cancel();
|
||||
self.inner.cell_tasks.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: SessionRuntimeDelegate> Drop for SessionRuntime<D> {
|
||||
fn drop(&mut self) {
|
||||
self.begin_shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell admitted by [`SessionRuntime::execute`].
|
||||
pub(crate) struct StartedCell {
|
||||
pub(crate) cell_id: CellId,
|
||||
initial_event: RuntimeEventFuture,
|
||||
}
|
||||
|
||||
impl StartedCell {
|
||||
pub(crate) async fn initial_event(self) -> Result<CellEvent, Error> {
|
||||
self.initial_event.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An admitted observation that has not reached its requested frontier yet.
|
||||
pub(crate) struct PendingEvent {
|
||||
event: RuntimeEventFuture,
|
||||
}
|
||||
|
||||
impl PendingEvent {
|
||||
pub(crate) async fn event(self) -> Result<CellEvent, Error> {
|
||||
self.event.await
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeCellHost<D: SessionRuntimeDelegate> {
|
||||
cell_id: CellId,
|
||||
inner: Arc<Inner<D>>,
|
||||
}
|
||||
|
||||
impl<D: SessionRuntimeDelegate> CellHost for RuntimeCellHost<D> {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
invocation: CellToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
self.inner
|
||||
.delegate
|
||||
.invoke_tool(
|
||||
NestedToolCall {
|
||||
cell_id: self.cell_id.clone(),
|
||||
runtime_tool_call_id: invocation.id,
|
||||
tool_name: invocation.name,
|
||||
tool_kind: invocation.kind,
|
||||
input: invocation.input,
|
||||
},
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
call_id: String,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
self.inner
|
||||
.delegate
|
||||
.notify(call_id, self.cell_id.clone(), text, cancellation_token)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
stored_value_writes: HashMap<String, JsonValue>,
|
||||
event: CellEvent,
|
||||
pending_initial_yield_items: Option<Vec<OutputItem>>,
|
||||
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);
|
||||
}
|
||||
stored_values = self.inner.stored_values.lock() => stored_values,
|
||||
};
|
||||
cell_state.commit_completion(event, pending_initial_yield_items, || {
|
||||
stored_values.extend(stored_value_writes);
|
||||
})
|
||||
}
|
||||
|
||||
async fn closed(&self) {
|
||||
self.inner.cells.lock().await.remove(&self.cell_id);
|
||||
self.inner.delegate.cell_closed(&self.cell_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn map_actor_event(cell_id: CellId, event: CellEventFuture) -> RuntimeEventFuture {
|
||||
Box::pin(async move { event.await.map_err(|error| actor_error(&cell_id, error)) })
|
||||
}
|
||||
|
||||
fn actor_error(cell_id: &CellId, error: CellError) -> Error {
|
||||
match error {
|
||||
CellError::Busy => Error::BusyObserver(cell_id.clone()),
|
||||
CellError::AlreadyTerminating => Error::AlreadyTerminating(cell_id.clone()),
|
||||
CellError::Closed => Error::ClosedCell(cell_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,265 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use std::task::Waker;
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::cell_actor::CompletionCommit;
|
||||
|
||||
struct RecordingDelegate;
|
||||
|
||||
struct PanickingClosedDelegate;
|
||||
|
||||
impl SessionRuntimeDelegate for RecordingDelegate {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
_invocation: NestedToolCall,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
Ok(JsonValue::Null)
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
_call_id: String,
|
||||
_cell_id: CellId,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cell_closed(&self, _cell_id: &CellId) {}
|
||||
}
|
||||
|
||||
impl SessionRuntimeDelegate for PanickingClosedDelegate {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
_invocation: NestedToolCall,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
Ok(JsonValue::Null)
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
_call_id: String,
|
||||
_cell_id: CellId,
|
||||
_text: String,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cell_closed(&self, _cell_id: &CellId) {
|
||||
panic!("cell close panic probe");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_cell_actor_panics_to_the_owner() {
|
||||
let (failure_tx, mut failure_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let runtime = SessionRuntime::new_with_task_failure_handler(
|
||||
Arc::new(PanickingClosedDelegate),
|
||||
Some(Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
);
|
||||
let started = runtime
|
||||
.execute(
|
||||
execute_request(r#"text("done");"#),
|
||||
ObserveMode::YieldAfter(Duration::from_secs(1)),
|
||||
)
|
||||
.await
|
||||
.expect("start cell");
|
||||
assert_eq!(
|
||||
started.initial_event().await,
|
||||
Ok(CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
})
|
||||
);
|
||||
runtime.shutdown().await.expect("shutdown runtime");
|
||||
let failure = failure_rx
|
||||
.try_recv()
|
||||
.expect("shutdown should wait for the cell failure watcher");
|
||||
assert!(failure.contains("code-mode cell 1 task failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_load_it() {
|
||||
let runtime = SessionRuntime::new(Arc::new(RecordingDelegate));
|
||||
let cell_state = Arc::new(CellState::new(CancellationToken::new()));
|
||||
let host = RuntimeCellHost {
|
||||
cell_id: CellId::new("terminating-writer"),
|
||||
inner: Arc::clone(&runtime.inner),
|
||||
};
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "uncommitted output".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
};
|
||||
|
||||
let stored_values = runtime.inner.stored_values.lock().await;
|
||||
let commit = host.commit_completion(
|
||||
HashMap::from([(
|
||||
"candidate".to_string(),
|
||||
JsonValue::String("lost".to_string()),
|
||||
)]),
|
||||
completion.clone(),
|
||||
/*pending_initial_yield_items*/ None,
|
||||
Arc::clone(&cell_state),
|
||||
);
|
||||
tokio::pin!(commit);
|
||||
let waker = Waker::noop();
|
||||
let mut context = Context::from_waker(waker);
|
||||
assert!(matches!(commit.as_mut().poll(&mut context), Poll::Pending));
|
||||
|
||||
let termination = cell_state.request_termination();
|
||||
drop(stored_values);
|
||||
assert_eq!(commit.await, CompletionCommit::Rejected(completion));
|
||||
let terminated = CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(terminated.clone()),
|
||||
Some(terminated.clone())
|
||||
);
|
||||
assert_eq!(termination.await, Ok(terminated));
|
||||
assert!(
|
||||
!runtime
|
||||
.inner
|
||||
.stored_values
|
||||
.lock()
|
||||
.await
|
||||
.contains_key("candidate")
|
||||
);
|
||||
|
||||
let reader = runtime
|
||||
.execute(
|
||||
CreateCellRequest {
|
||||
tool_call_id: "reader".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: r#"text(String(load("candidate")));"#.to_string(),
|
||||
},
|
||||
ObserveMode::YieldAfter(Duration::from_secs(1)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reader.initial_event().await,
|
||||
Ok(CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "undefined".to_string(),
|
||||
}],
|
||||
error_text: None,
|
||||
})
|
||||
);
|
||||
runtime.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
fn execute_request(source: &str) -> CreateCellRequest {
|
||||
CreateCellRequest {
|
||||
tool_call_id: "call-1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cell_id_allocation_fails_before_wrapping() {
|
||||
let runtime = SessionRuntime::new(Arc::new(RecordingDelegate));
|
||||
runtime
|
||||
.inner
|
||||
.next_cell_id
|
||||
.store(u64::MAX, Ordering::Relaxed);
|
||||
|
||||
assert_eq!(
|
||||
runtime
|
||||
.execute(
|
||||
execute_request(r#"text("unreachable");"#),
|
||||
ObserveMode::YieldAfter(Duration::from_secs(1)),
|
||||
)
|
||||
.await
|
||||
.err(),
|
||||
Some(Error::CellIdSpaceExhausted)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "test holds the registry lock to force admission ahead of shutdown"
|
||||
)]
|
||||
async fn shutdown_rejects_cell_admission_queued_before_the_registry_lock() {
|
||||
let runtime = Arc::new(SessionRuntime::new(Arc::new(RecordingDelegate)));
|
||||
let cells = runtime.inner.cells.lock().await;
|
||||
|
||||
let execution = runtime.execute(
|
||||
execute_request("while (true) {}"),
|
||||
ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)),
|
||||
);
|
||||
tokio::pin!(execution);
|
||||
std::future::poll_fn(|context| match execution.as_mut().poll(context) {
|
||||
Poll::Pending => Poll::Ready(()),
|
||||
Poll::Ready(Ok(_)) => panic!("execution completed before the registry lock was released"),
|
||||
Poll::Ready(Err(error)) => {
|
||||
panic!("execution failed before the registry lock was released: {error}")
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let shutdown = runtime.shutdown();
|
||||
tokio::pin!(shutdown);
|
||||
std::future::poll_fn(|context| match shutdown.as_mut().poll(context) {
|
||||
Poll::Pending => Poll::Ready(()),
|
||||
Poll::Ready(Ok(())) => panic!("shutdown completed before acquiring the registry lock"),
|
||||
Poll::Ready(Err(error)) => {
|
||||
panic!("shutdown failed before acquiring the registry lock: {error}")
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
drop(cells);
|
||||
assert!(matches!(execution.await, Err(Error::ShuttingDown)));
|
||||
assert_eq!(shutdown.await, Ok(()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_terminates_cells_when_the_registry_is_locked() {
|
||||
let runtime = SessionRuntime::new(Arc::new(RecordingDelegate));
|
||||
let started = runtime
|
||||
.execute(
|
||||
execute_request("while (true) {}"),
|
||||
ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(started.cell_id, CellId::new("1"));
|
||||
assert_eq!(
|
||||
started.initial_event().await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: Vec::new(),
|
||||
})
|
||||
);
|
||||
|
||||
let inner = Arc::clone(&runtime.inner);
|
||||
let cells = inner.cells.lock().await;
|
||||
drop(runtime);
|
||||
drop(cells);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(/*secs*/ 1), inner.cell_tasks.wait())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(inner.cell_tasks.is_empty());
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Identifies one execution cell within a session runtime.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct CellId(String);
|
||||
|
||||
impl CellId {
|
||||
pub(crate) fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CellId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the next observable frontier for a running cell.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ObserveMode {
|
||||
YieldAfter(Duration),
|
||||
PendingFrontier,
|
||||
}
|
||||
|
||||
/// An observable cell lifecycle event.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum CellEvent {
|
||||
Yielded {
|
||||
content_items: Vec<OutputItem>,
|
||||
},
|
||||
Pending {
|
||||
content_items: Vec<OutputItem>,
|
||||
pending_tool_call_ids: Vec<String>,
|
||||
},
|
||||
Completed {
|
||||
content_items: Vec<OutputItem>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
Terminated {
|
||||
content_items: Vec<OutputItem>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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) tool_call_id: String,
|
||||
pub(crate) enabled_tools: Vec<ToolDefinition>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
/// Tool metadata exposed to code running inside a cell.
|
||||
pub(crate) struct ToolDefinition {
|
||||
pub(crate) name: String,
|
||||
pub(crate) tool_name: ToolName,
|
||||
pub(crate) description: String,
|
||||
pub(crate) kind: ToolKind,
|
||||
}
|
||||
|
||||
/// A tool name with an optional namespace.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ToolName {
|
||||
pub(crate) name: String,
|
||||
pub(crate) namespace: Option<String>,
|
||||
}
|
||||
|
||||
/// The JavaScript calling convention for a tool.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ToolKind {
|
||||
Function,
|
||||
Freeform,
|
||||
}
|
||||
|
||||
/// A nested tool request emitted by a running cell.
|
||||
pub(crate) struct NestedToolCall {
|
||||
pub(crate) cell_id: CellId,
|
||||
pub(crate) runtime_tool_call_id: String,
|
||||
pub(crate) tool_name: ToolName,
|
||||
pub(crate) tool_kind: ToolKind,
|
||||
pub(crate) input: Option<JsonValue>,
|
||||
}
|
||||
|
||||
/// Host callbacks used by cells owned by a [`super::SessionRuntime`].
|
||||
///
|
||||
/// Implementations must honor cancellation tokens. `cell_closed` is called
|
||||
/// after the runtime has stopped routing requests to the cell.
|
||||
pub(crate) trait SessionRuntimeDelegate: Send + Sync + 'static {
|
||||
fn invoke_tool(
|
||||
&self,
|
||||
invocation: NestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl Future<Output = Result<JsonValue, String>> + Send;
|
||||
|
||||
fn notify(
|
||||
&self,
|
||||
call_id: String,
|
||||
cell_id: CellId,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl Future<Output = Result<(), String>> + Send;
|
||||
|
||||
fn cell_closed(&self, cell_id: &CellId);
|
||||
}
|
||||
|
||||
/// A failure reported by a session runtime operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Error {
|
||||
ShuttingDown,
|
||||
CellIdSpaceExhausted,
|
||||
DuplicateCell(CellId),
|
||||
MissingCell(CellId),
|
||||
BusyObserver(CellId),
|
||||
AlreadyTerminating(CellId),
|
||||
ClosedCell(CellId),
|
||||
Runtime(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ShuttingDown => formatter.write_str("code mode session is shutting down"),
|
||||
Self::CellIdSpaceExhausted => {
|
||||
formatter.write_str("code mode session exhausted its cell ID space")
|
||||
}
|
||||
Self::DuplicateCell(cell_id) => write!(formatter, "exec cell {cell_id} already exists"),
|
||||
Self::MissingCell(cell_id) => write!(formatter, "exec cell {cell_id} not found"),
|
||||
Self::BusyObserver(cell_id) => {
|
||||
write!(
|
||||
formatter,
|
||||
"exec cell {cell_id} already has an active observer"
|
||||
)
|
||||
}
|
||||
Self::AlreadyTerminating(cell_id) => {
|
||||
write!(formatter, "exec cell {cell_id} is already terminating")
|
||||
}
|
||||
Self::ClosedCell(cell_id) => {
|
||||
write!(formatter, "exec cell {cell_id} closed unexpectedly")
|
||||
}
|
||||
Self::Runtime(error_text) => formatter.write_str(error_text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Controls whether V8 may generate executable code at runtime.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum V8JitMode {
|
||||
#[default]
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
struct V8Initialization {
|
||||
_platform: v8::SharedRef<v8::Platform>,
|
||||
jit_mode: V8JitMode,
|
||||
}
|
||||
|
||||
static V8_INITIALIZATION: OnceLock<Result<V8Initialization, String>> = OnceLock::new();
|
||||
|
||||
/// Initializes the process-wide V8 platform with the requested JIT mode.
|
||||
///
|
||||
/// Call this before executing any code-mode cells when JIT must be disabled.
|
||||
/// V8 cannot change JIT mode after initialization, so a later call requesting
|
||||
/// a different mode returns an error. Code mode initializes V8 with JIT enabled
|
||||
/// by default when this function has not been called explicitly.
|
||||
pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> {
|
||||
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) {
|
||||
Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()),
|
||||
Ok(initialization) => Err(format!(
|
||||
"V8 was already initialized with JIT {}",
|
||||
initialization.jit_mode.description()
|
||||
)),
|
||||
Err(error_text) => Err(error_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_v8_initialized() -> Result<(), String> {
|
||||
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error_text) => Err(error_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result<V8Initialization, String> {
|
||||
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
|
||||
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
|
||||
match jit_mode {
|
||||
V8JitMode::Enabled => {}
|
||||
V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"),
|
||||
}
|
||||
let platform = v8::new_default_platform(0, false).make_shared();
|
||||
v8::V8::initialize_platform(platform.clone());
|
||||
v8::V8::initialize();
|
||||
Ok(V8Initialization {
|
||||
_platform: platform,
|
||||
jit_mode,
|
||||
})
|
||||
}
|
||||
|
||||
impl V8JitMode {
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enabled => "enabled",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use codex_code_mode::ExecuteRequest;
|
||||
use codex_code_mode::InProcessCodeModeSession;
|
||||
use codex_code_mode::RuntimeResponse;
|
||||
use codex_code_mode::V8JitMode;
|
||||
use codex_code_mode::initialize_v8;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_mode_runs_with_jit_disabled() {
|
||||
initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT");
|
||||
|
||||
let service = InProcessCodeModeSession::new();
|
||||
let started = service
|
||||
.execute(ExecuteRequest {
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: "21 * 2;".to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
})
|
||||
.await
|
||||
.expect("start code-mode cell");
|
||||
let cell_id = started.cell_id.clone();
|
||||
let response = started
|
||||
.initial_response()
|
||||
.await
|
||||
.expect("execute code-mode cell");
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id,
|
||||
content_items: Vec::new(),
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
initialize_v8(V8JitMode::Enabled),
|
||||
Err("V8 was already initialized with JIT disabled".to_string())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user