mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
e2f074e16c
A code-mode cell is a single JavaScript execution that can produce output, call tools, wait for asynchronous work, resume, or be terminated. This PR extracts the existing per-cell run loop into a dedicated actor that owns the cell’s lifecycle state. It is primarily an ownership change rather than a new lifecycle contract: existing behavior now has one clear implementation boundary. ### Architecture The session service remains responsible for session-wide concerns: allocating cell IDs, storing shared values, creating cells, and routing requests to them. Once a cell is created, its execution state belongs to its actor. Callers interact with the actor through a handle. The actor receives two kinds of input: runtime events and control requests. A single event loop serializes these inputs and applies the lifecycle rules. It tracks the current observer—the caller waiting for an update—along with accumulated output, outstanding callbacks, runtime state, yield deadlines, and termination progress. Observation, termination, completion, and cleanup therefore have one consistent owner. When the runtime has no immediately runnable work and is waiting only on timers or tool results, the actor can return accumulated output and information about outstanding tool calls while keeping the cell available to resume. On completion or termination, it performs the appropriate callback cleanup before publishing the final result and removing the cell from the session. A small host interface connects the actor to session-owned facilities such as tool dispatch, notifications, stored values, and final cell removal, keeping those responsibilities outside the actor itself. ### Why Previously, cell lifecycle state and coordination lived alongside session management. The actor boundary makes each cell a self-contained state machine with a single writer, while the service becomes a registry and adapter around it. This makes lifecycle behavior easier to reason about and test in isolation. It also establishes a clean boundary for later changing where cells run or how they communicate without recreating their lifecycle rules.
973 lines
25 KiB
Rust
973 lines
25 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use super::CellId;
|
|
use super::CodeModeNestedToolCall;
|
|
use super::CodeModeService;
|
|
use super::CodeModeSessionDelegate;
|
|
use super::NotificationFuture;
|
|
use super::ObserveMode;
|
|
use super::RuntimeResponse;
|
|
use super::ToolInvocationFuture;
|
|
use super::WaitOutcome;
|
|
use super::WaitRequest;
|
|
use super::WaitToPendingOutcome;
|
|
use super::WaitToPendingRequest;
|
|
use crate::CodeModeToolKind;
|
|
use crate::ExecuteRequest;
|
|
use crate::ExecuteToPendingOutcome;
|
|
use crate::FunctionCallOutputContentItem;
|
|
use crate::ToolDefinition;
|
|
use codex_protocol::ToolName;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value as JsonValue;
|
|
use tokio::sync::Notify;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[derive(Default)]
|
|
struct ReleasableToolDelegate {
|
|
tool_release: Notify,
|
|
}
|
|
|
|
impl ReleasableToolDelegate {
|
|
fn release_tool(&self) {
|
|
self.tool_release.notify_one();
|
|
}
|
|
}
|
|
|
|
impl CodeModeSessionDelegate for ReleasableToolDelegate {
|
|
fn invoke_tool<'a>(
|
|
&'a self,
|
|
_invocation: CodeModeNestedToolCall,
|
|
cancellation_token: CancellationToken,
|
|
) -> ToolInvocationFuture<'a> {
|
|
Box::pin(async move {
|
|
tokio::select! {
|
|
_ = self.tool_release.notified() => Ok(JsonValue::Null),
|
|
_ = cancellation_token.cancelled() => 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 { Ok(()) })
|
|
}
|
|
|
|
fn cell_closed(&self, _cell_id: &CellId) {}
|
|
}
|
|
|
|
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 cell_id(value: &str) -> CellId {
|
|
CellId::new(value.to_string())
|
|
}
|
|
|
|
fn echo_tool() -> ToolDefinition {
|
|
ToolDefinition {
|
|
name: "echo".to_string(),
|
|
tool_name: ToolName::plain("echo"),
|
|
description: String::new(),
|
|
kind: CodeModeToolKind::Function,
|
|
input_schema: None,
|
|
output_schema: None,
|
|
}
|
|
}
|
|
|
|
async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse {
|
|
service
|
|
.execute(request)
|
|
.await
|
|
.unwrap()
|
|
.initial_response()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn synchronous_exit_returns_successfully() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"text("before"); exit(); text("after");"#.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "before".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stored_values_are_shared_between_cells_but_not_sessions() {
|
|
let first_session = CodeModeService::new();
|
|
let second_session = CodeModeService::new();
|
|
|
|
let write_response = execute(
|
|
&first_session,
|
|
ExecuteRequest {
|
|
source: r#"store("key", "visible");"#.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let same_session = execute(
|
|
&first_session,
|
|
ExecuteRequest {
|
|
source: r#"text(String(load("key")));"#.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
let other_session = execute(
|
|
&second_session,
|
|
ExecuteRequest {
|
|
source: r#"text(String(load("key")));"#.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
write_response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
error_text: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
same_session,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("2"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "visible".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
other_session,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "undefined".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_interrupts_cpu_bound_cells() {
|
|
let service = CodeModeService::new();
|
|
|
|
let cell = service
|
|
.execute(ExecuteRequest {
|
|
source: "while (true) {}".to_string(),
|
|
..execute_request("")
|
|
})
|
|
.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), service.shutdown())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_cell_rejects_new_cell_after_shutdown_begins() {
|
|
let service = CodeModeService::new();
|
|
service.inner.shutting_down.store(true, Ordering::Release);
|
|
|
|
let error = service
|
|
.start_cell(
|
|
cell_id("late-cell"),
|
|
execute_request(""),
|
|
ObserveMode::YieldAfter(Duration::from_millis(1)),
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap();
|
|
|
|
assert_eq!(error, "code mode session is shutting down".to_string());
|
|
assert!(service.inner.cells.lock().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_to_pending_returns_completed_for_synchronous_results() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = service
|
|
.execute_to_pending(ExecuteRequest {
|
|
source: r#"text("done");"#.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response,
|
|
ExecuteToPendingOutcome::Completed(RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "done".to_string(),
|
|
}],
|
|
error_text: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_to_pending_returns_once_the_runtime_is_quiescent() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
service.execute_to_pending(ExecuteRequest {
|
|
source: r#"text("before"); await new Promise(() => {});"#.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response,
|
|
ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "before".to_string(),
|
|
}],
|
|
pending_tool_call_ids: Vec::new(),
|
|
}
|
|
);
|
|
|
|
let termination = service.terminate(cell_id("1")).await.unwrap();
|
|
|
|
assert_eq!(
|
|
termination,
|
|
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = service
|
|
.execute_to_pending(ExecuteRequest {
|
|
enabled_tools: vec![echo_tool()],
|
|
source: r#"
|
|
await Promise.all([
|
|
tools.echo({ value: "first" }),
|
|
tools.echo({ value: "second" }),
|
|
]);
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response,
|
|
ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()],
|
|
}
|
|
);
|
|
|
|
let termination = service.terminate(cell_id("1")).await.unwrap();
|
|
|
|
assert_eq!(
|
|
termination,
|
|
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() {
|
|
let service = CodeModeService::new();
|
|
|
|
let initial_response = service
|
|
.execute_to_pending(ExecuteRequest {
|
|
enabled_tools: vec![echo_tool()],
|
|
source: r#"
|
|
setTimeout(() => {
|
|
tools.echo({ value: "delayed" });
|
|
}, 1000);
|
|
await Promise.all([
|
|
tools.echo({ value: "second" }),
|
|
tools.echo({ value: "third" }),
|
|
]);
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
initial_response,
|
|
ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()],
|
|
}
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
|
|
|
let resumed_response = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
service.wait_to_pending(WaitToPendingRequest {
|
|
cell_id: cell_id("1"),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resumed_response,
|
|
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
pending_tool_call_ids: vec!["tool-3".to_string()],
|
|
})
|
|
);
|
|
|
|
let termination = service.terminate(cell_id("1")).await.unwrap();
|
|
|
|
assert_eq!(
|
|
termination,
|
|
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() {
|
|
let delegate = Arc::new(ReleasableToolDelegate::default());
|
|
let service = CodeModeService::with_delegate(delegate.clone());
|
|
|
|
let initial_response = service
|
|
.execute_to_pending(ExecuteRequest {
|
|
enabled_tools: vec![echo_tool()],
|
|
source: r#"
|
|
await tools.echo({});
|
|
text("after");
|
|
await new Promise(() => {});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
initial_response,
|
|
ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
pending_tool_call_ids: vec!["tool-1".to_string()],
|
|
}
|
|
);
|
|
|
|
delegate.release_tool();
|
|
|
|
let resumed_response = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
service.wait_to_pending(WaitToPendingRequest {
|
|
cell_id: cell_id("1"),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resumed_response,
|
|
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "after".to_string(),
|
|
}],
|
|
pending_tool_call_ids: Vec::new(),
|
|
})
|
|
);
|
|
|
|
let termination = service.terminate(cell_id("1")).await.unwrap();
|
|
|
|
assert_eq!(
|
|
termination,
|
|
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() {
|
|
let delegate = Arc::new(ReleasableToolDelegate::default());
|
|
let service = CodeModeService::with_delegate(delegate.clone());
|
|
|
|
let initial_response = service
|
|
.execute_to_pending(ExecuteRequest {
|
|
enabled_tools: vec![echo_tool()],
|
|
source: r#"
|
|
await tools.echo({});
|
|
text("done");
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: Some(60_000),
|
|
..execute_request("")
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
initial_response,
|
|
ExecuteToPendingOutcome::Pending {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
pending_tool_call_ids: vec!["tool-1".to_string()],
|
|
}
|
|
);
|
|
|
|
delegate.release_tool();
|
|
|
|
let resumed_response = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
service.wait_to_pending(WaitToPendingRequest {
|
|
cell_id: cell_id("1"),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resumed_response,
|
|
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed(
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "done".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v8_console_is_not_exposed_on_global_this() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "false".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn date_locale_string_formats_with_icu_data() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
const value = new Date("2025-01-02T03:04:05Z")
|
|
.toLocaleString("fr-FR", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
timeZone: "UTC",
|
|
});
|
|
text(value);
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn intl_date_time_format_formats_with_icu_data() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
const formatter = new Intl.DateTimeFormat("fr-FR", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
timeZone: "UTC",
|
|
});
|
|
text(formatter.format(new Date("2025-01-02T03:04:05Z")));
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputText {
|
|
text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn output_helpers_return_undefined() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
const returnsUndefined = [
|
|
text("first"),
|
|
image("data:image/png;base64,AAA"),
|
|
notify("ping"),
|
|
].map((value) => value === undefined);
|
|
text(JSON.stringify(returnsUndefined));
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "first".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "[true,true,true]".to_string(),
|
|
},
|
|
],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image({
|
|
type: "image",
|
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
|
mimeType: "image/png",
|
|
_meta: { "codex/imageDetail": "original" },
|
|
});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(),
|
|
detail: Some(crate::ImageDetail::Original),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn generated_image_helper_appends_image_and_output_hint() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
generatedImage({
|
|
image_url: "data:image/png;base64,AAA",
|
|
output_hint: "generated image save hint",
|
|
});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "generated image save hint".to_string(),
|
|
},
|
|
],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_second_arg_overrides_explicit_object_detail() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image(
|
|
{
|
|
image_url: "data:image/png;base64,AAA",
|
|
detail: "high",
|
|
},
|
|
"original",
|
|
);
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(crate::ImageDetail::Original),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_second_arg_overrides_raw_mcp_image_detail() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image(
|
|
{
|
|
type: "image",
|
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
|
mimeType: "image/png",
|
|
_meta: { "codex/imageDetail": "original" },
|
|
},
|
|
"high",
|
|
);
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(),
|
|
detail: Some(crate::ImageDetail::High),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_accepts_low_detail() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image({
|
|
image_url: "data:image/png;base64,AAA",
|
|
detail: "low",
|
|
});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: vec![FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(crate::ImageDetail::Low),
|
|
}],
|
|
error_text: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helpers_reject_remote_urls() {
|
|
for image_url in [
|
|
"http://example.com/image.jpg",
|
|
"https://example.com/image.jpg",
|
|
] {
|
|
for source in [
|
|
format!("image({image_url:?});"),
|
|
format!("generatedImage({{ image_url: {image_url:?} }});"),
|
|
] {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source,
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
error_text: Some(
|
|
"Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead".to_string(),
|
|
),
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_rejects_unsupported_detail() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image({
|
|
image_url: "data:image/png;base64,AAA",
|
|
detail: "medium",
|
|
});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
error_text: Some("image detail must be one of: auto, low, high, original".to_string()),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_helper_rejects_raw_mcp_result_container() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = execute(
|
|
&service,
|
|
ExecuteRequest {
|
|
source: r#"
|
|
image({
|
|
content: [
|
|
{
|
|
type: "image",
|
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
|
mimeType: "image/png",
|
|
_meta: { "codex/imageDetail": "original" },
|
|
},
|
|
],
|
|
isError: false,
|
|
});
|
|
"#
|
|
.to_string(),
|
|
yield_time_ms: None,
|
|
..execute_request("")
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
RuntimeResponse::Result {
|
|
cell_id: cell_id("1"),
|
|
content_items: Vec::new(),
|
|
error_text: Some(
|
|
"image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(),
|
|
),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wait_reports_missing_cell_separately_from_runtime_results() {
|
|
let service = CodeModeService::new();
|
|
|
|
let response = service
|
|
.wait(WaitRequest {
|
|
cell_id: cell_id("missing"),
|
|
yield_time_ms: 1,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response,
|
|
WaitOutcome::MissingCell(RuntimeResponse::Result {
|
|
cell_id: cell_id("missing"),
|
|
content_items: Vec::new(),
|
|
error_text: Some("exec cell missing not found".to_string()),
|
|
})
|
|
);
|
|
}
|