diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc7a50af3c..e69f32072d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2927,11 +2927,13 @@ dependencies = [ name = "codex-wasm-harness" version = "0.0.0" dependencies = [ + "async-trait", "codex-core", "js-sys", "pretty_assertions", "serde", "serde_json", + "tokio", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/codex-rs/wasm-harness/Cargo.toml b/codex-rs/wasm-harness/Cargo.toml index 91f654e4c3..eaa5428e28 100644 --- a/codex-rs/wasm-harness/Cargo.toml +++ b/codex-rs/wasm-harness/Cargo.toml @@ -16,6 +16,7 @@ workspace = true real-core = ["dep:codex-core"] [dependencies] +async-trait = { workspace = true } codex-core = { workspace = true, optional = true } js-sys = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -32,3 +33,4 @@ web-sys = { workspace = true, features = [ [dev-dependencies] pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md index 65ba4b6c75..33c9c7b357 100644 --- a/codex-rs/wasm-harness/README.md +++ b/codex-rs/wasm-harness/README.md @@ -2,6 +2,13 @@ This crate is the first browser-facing seam for a Codex harness prototype. +It now exposes two layers: + +- `EmbeddedHarness`: a pure Rust library API that downstream Rust/web repos can + depend on directly. +- `BrowserCodex`: a `wasm_bindgen` adapter that preserves the current browser + demo API. + It does not yet call `codex-core::run_turn` or `RegularTask::run`. Instead, it establishes the intended browser API shape: @@ -23,6 +30,26 @@ The next step is to replace the callback boundary with a real model transport and then wire the facade to the Codex turn loop after host services are injectable. +## Library Boundary + +The intended downstream integration point is the pure Rust API: + +- `EmbeddedHarness` +- `ResponsesClient` +- `ToolExecutor` +- `EventSink` +- `HarnessConfig` + +That allows downstream webapps to: + +- depend on `codex-wasm-harness` from a Git branch or local path; +- supply their own browser/runtime implementations for transport, tools, event + rendering, or persistence; and +- keep app-specific browser glue out of the Codex repo. + +The current `BrowserCodex` type remains a thin compatibility wrapper around +that library API so the demo page keeps working. + The `real-core` feature is an explicit compile probe for depending on `codex-core`. It currently does not build for `wasm32-unknown-unknown`; the first blocker is native Tokio/Mio networking pulled through the host-heavy diff --git a/codex-rs/wasm-harness/src/browser.rs b/codex-rs/wasm-harness/src/browser.rs new file mode 100644 index 0000000000..c8967a3e9d --- /dev/null +++ b/codex-rs/wasm-harness/src/browser.rs @@ -0,0 +1,271 @@ +use crate::EXEC_JS_TOOL_NAME; +use crate::EmbeddedHarness; +use crate::EventSink; +use crate::HarnessConfig; +use crate::HarnessError; +use crate::ResponsesClient; +use crate::ResponsesFunctionCall; +use crate::ResponsesRequest; +use crate::ResponsesResponse; +use crate::ResponsesTool; +use crate::ToolExecutor; +use async_trait::async_trait; +use js_sys::Function; +use js_sys::Promise; +use serde::Deserialize; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::Headers; +use web_sys::RequestInit; +use web_sys::RequestMode; +use web_sys::Response; + +const RESPONSES_API_URL: &str = "https://api.openai.com/v1/responses"; + +#[derive(Default)] +struct BrowserResponsesClient { + api_key: String, +} + +impl BrowserResponsesClient { + fn set_api_key(&mut self, api_key: String) { + self.api_key = api_key; + } +} + +#[async_trait(?Send)] +impl ResponsesClient for BrowserResponsesClient { + async fn create_response( + &self, + request: ResponsesRequest, + ) -> Result { + if self.api_key.trim().is_empty() { + return Ok(default_demo_response(&request.input.to_string())); + } + + let body = serde_json::to_string(&request)?; + + let headers = Headers::new().map_err(js_exception)?; + headers + .append("Authorization", &format!("Bearer {}", self.api_key.trim())) + .map_err(js_exception)?; + headers + .append("Content-Type", "application/json") + .map_err(js_exception)?; + + let request_init = RequestInit::new(); + request_init.set_method("POST"); + request_init.set_mode(RequestMode::Cors); + request_init.set_headers(&headers); + request_init.set_body(&JsValue::from_str(&body)); + + let window = web_sys::window().ok_or_else(|| HarnessError::new("window is unavailable"))?; + let response_value = + JsFuture::from(window.fetch_with_str_and_init(RESPONSES_API_URL, &request_init)) + .await + .map_err(js_fetch_error)?; + let response: Response = response_value.dyn_into().map_err(js_exception)?; + let status = response.status(); + let ok = response.ok(); + let json = JsFuture::from(response.json().map_err(js_exception)?) + .await + .map_err(js_fetch_error)?; + let response_body = parse_response_body(json)?; + + if !ok { + let message = response_body + .error + .as_ref() + .and_then(|err| err.message.clone()) + .unwrap_or_else(|| format!("Responses API returned {status}")); + return Err(HarnessError::new(message)); + } + + Ok(response_body) + } +} + +#[derive(Default)] +struct BrowserToolExecutor { + code_executor: Option, +} + +impl BrowserToolExecutor { + fn set_code_executor(&mut self, executor: Function) { + self.code_executor = Some(executor); + } + + fn clear_code_executor(&mut self) { + self.code_executor = None; + } +} + +#[async_trait(?Send)] +impl ToolExecutor for BrowserToolExecutor { + fn tools(&self) -> Vec { + self.code_executor + .as_ref() + .map(|_| vec![ResponsesTool::exec_js()]) + .unwrap_or_default() + } + + async fn execute(&self, function_call: &ResponsesFunctionCall) -> Result { + if function_call.name != EXEC_JS_TOOL_NAME { + return Err(HarnessError::new(format!( + "browser prototype does not implement tool `{}`", + function_call.name + ))); + } + + let executor = self.code_executor.as_ref().ok_or_else(|| { + HarnessError::new("`exec_js` was requested but no browser executor is registered") + })?; + let args: ExecJsArguments = serde_json::from_str(&function_call.arguments)?; + let value = executor + .call1(&JsValue::NULL, &JsValue::from_str(&args.code)) + .map_err(js_exception)?; + let value = await_possible_promise(value).await?; + js_value_to_string(value) + } +} + +struct JsEventSink<'a> { + on_event: &'a Function, +} + +impl EventSink for JsEventSink<'_> { + fn emit(&self, event: &crate::HarnessEvent) -> Result<(), HarnessError> { + let json = serde_json::to_string(event)?; + let value = js_sys::JSON::parse(&json).map_err(js_exception)?; + self.on_event + .call1(&JsValue::NULL, &value) + .map_err(js_exception)?; + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct ExecJsArguments { + code: String, +} + +/// Browser entrypoint for the prototype harness. +#[wasm_bindgen] +pub struct BrowserCodex { + harness: EmbeddedHarness, +} + +#[wasm_bindgen] +impl BrowserCodex { + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new(api_key: String) -> Self { + let mut client = BrowserResponsesClient::default(); + client.set_api_key(api_key); + let tool_executor = BrowserToolExecutor::default(); + let harness = EmbeddedHarness::new(HarnessConfig::default(), client, tool_executor); + Self { harness } + } + + pub fn set_api_key(&mut self, api_key: String) { + self.harness.responses_client_mut().set_api_key(api_key); + } + + pub fn set_code_executor(&mut self, executor: Function) { + self.harness.tool_executor_mut().set_code_executor(executor); + } + + pub fn clear_code_executor(&mut self) { + self.harness.tool_executor_mut().clear_code_executor(); + } + + pub async fn submit_turn( + &mut self, + prompt: String, + on_event: Function, + ) -> Result { + let sink = JsEventSink { + on_event: &on_event, + }; + let agent_message = self + .harness + .submit_turn(prompt, &sink) + .await + .map_err(harness_error_to_js)?; + Ok(JsValue::from_str(&agent_message)) + } +} + +fn parse_response_body(value: JsValue) -> Result { + let json = js_sys::JSON::stringify(&value) + .map_err(js_exception)? + .as_string() + .ok_or_else(|| HarnessError::new("Responses API returned non-JSON output"))?; + serde_json::from_str(&json).map_err(HarnessError::from) +} + +async fn await_possible_promise(value: JsValue) -> Result { + if let Ok(promise) = value.clone().dyn_into::() { + JsFuture::from(promise).await.map_err(js_exception) + } else { + Ok(value) + } +} + +fn js_value_to_string(value: JsValue) -> Result { + if let Some(text) = value.as_string() { + return Ok(text); + } + + if value.is_undefined() || value.is_null() { + return Ok(String::new()); + } + + let json = js_sys::JSON::stringify(&value).map_err(js_exception)?; + Ok(json + .as_string() + .unwrap_or_else(|| "[non-string value]".to_string())) +} + +fn js_exception(error: JsValue) -> HarnessError { + HarnessError::new(js_value_to_string_lossy(&error)) +} + +fn js_fetch_error(error: JsValue) -> HarnessError { + HarnessError::new(format!( + "browser fetch failed: {}", + js_value_to_string_lossy(&error) + )) +} + +fn js_value_to_string_lossy(value: &JsValue) -> String { + if let Some(text) = value.as_string() { + return text; + } + + js_sys::JSON::stringify(value) + .ok() + .and_then(|text| text.as_string()) + .unwrap_or_else(|| "[non-string javascript error]".to_string()) +} + +fn harness_error_to_js(error: HarnessError) -> JsValue { + JsValue::from_str(error.message()) +} + +fn default_demo_response(input: &str) -> ResponsesResponse { + let prompt = serde_json::from_str::(input).unwrap_or_else(|_| input.to_string()); + let output_text = if prompt.to_ascii_lowercase().contains("hello world") { + "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```" + .to_string() + } else { + format!("Demo mode is active because no API key was provided. Prompt received:\n\n{prompt}") + }; + ResponsesResponse { + id: Some("demo-response".to_string()), + output_text: Some(output_text), + output: Some(Vec::new()), + error: None, + } +} diff --git a/codex-rs/wasm-harness/src/error.rs b/codex-rs/wasm-harness/src/error.rs new file mode 100644 index 0000000000..d52cb1a684 --- /dev/null +++ b/codex-rs/wasm-harness/src/error.rs @@ -0,0 +1,43 @@ +use std::error::Error; +use std::fmt; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HarnessError(String); + +impl HarnessError { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } + + #[must_use] + pub fn message(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for HarnessError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Error for HarnessError {} + +impl From<&str> for HarnessError { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for HarnessError { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From for HarnessError { + fn from(value: serde_json::Error) -> Self { + Self::new(value.to_string()) + } +} diff --git a/codex-rs/wasm-harness/src/harness.rs b/codex-rs/wasm-harness/src/harness.rs new file mode 100644 index 0000000000..a57f09be75 --- /dev/null +++ b/codex-rs/wasm-harness/src/harness.rs @@ -0,0 +1,374 @@ +use crate::HarnessError; +use crate::responses::HarnessEvent; +use crate::responses::ResponsesFunctionCall; +use crate::responses::ResponsesRequest; +use crate::responses::ResponsesResponse; +use crate::responses::ResponsesTool; +use crate::responses::build_browser_instructions; +use crate::responses::tool_output_item; +use async_trait::async_trait; +use serde_json::Value; + +const DEFAULT_MODEL: &str = "gpt-5.1"; +const DEFAULT_MAX_TOOL_ROUNDS: usize = 8; + +#[derive(Clone, Debug)] +pub struct HarnessConfig { + pub model: String, + pub instructions: String, + pub max_tool_rounds: usize, +} + +impl Default for HarnessConfig { + fn default() -> Self { + Self { + model: DEFAULT_MODEL.to_string(), + instructions: build_browser_instructions(), + max_tool_rounds: DEFAULT_MAX_TOOL_ROUNDS, + } + } +} + +pub trait EventSink { + fn emit(&self, event: &HarnessEvent) -> Result<(), HarnessError>; +} + +#[async_trait(?Send)] +pub trait ResponsesClient { + async fn create_response( + &self, + request: ResponsesRequest, + ) -> Result; +} + +#[async_trait(?Send)] +pub trait ToolExecutor { + fn tools(&self) -> Vec; + + async fn execute(&self, function_call: &ResponsesFunctionCall) -> Result; +} + +pub struct EmbeddedHarness { + config: HarnessConfig, + responses_client: C, + tool_executor: T, + next_turn_id: u32, +} + +impl EmbeddedHarness { + #[must_use] + pub fn new(config: HarnessConfig, responses_client: C, tool_executor: T) -> Self { + Self { + config, + responses_client, + tool_executor, + next_turn_id: 0, + } + } + + pub fn responses_client_mut(&mut self) -> &mut C { + &mut self.responses_client + } + + pub fn tool_executor_mut(&mut self) -> &mut T { + &mut self.tool_executor + } + + pub fn config_mut(&mut self) -> &mut HarnessConfig { + &mut self.config + } +} + +impl EmbeddedHarness +where + C: ResponsesClient, + T: ToolExecutor, +{ + pub async fn submit_turn( + &mut self, + prompt: impl Into, + event_sink: &E, + ) -> Result { + self.next_turn_id += 1; + let turn_id = format!("browser-turn-{}", self.next_turn_id); + let prompt = prompt.into(); + + event_sink.emit(&HarnessEvent::TurnStarted { + turn_id: turn_id.clone(), + model_context_window: None, + collaboration_mode_kind: "default".to_string(), + })?; + event_sink.emit(&HarnessEvent::UserMessage { + turn_id: turn_id.clone(), + message: prompt.clone(), + })?; + + let result = self.run_turn(&turn_id, &prompt, event_sink).await; + match result { + Ok(last_agent_message) => { + event_sink.emit(&HarnessEvent::TurnComplete { + turn_id, + last_agent_message: Some(last_agent_message.clone()), + })?; + Ok(last_agent_message) + } + Err(err) => { + event_sink.emit(&HarnessEvent::TurnError { + turn_id: turn_id.clone(), + message: err.to_string(), + })?; + event_sink.emit(&HarnessEvent::TurnComplete { + turn_id, + last_agent_message: None, + })?; + Err(err) + } + } + } + + async fn run_turn( + &self, + turn_id: &str, + prompt: &str, + event_sink: &E, + ) -> Result { + let mut previous_response_id: Option = None; + let mut input = Value::String(prompt.to_string()); + let mut last_agent_message: Option = None; + + for round in 0..self.config.max_tool_rounds { + let tools = self.tool_executor.tools(); + let request = ResponsesRequest { + model: self.config.model.clone(), + instructions: self.config.instructions.clone(), + input, + previous_response_id: previous_response_id.clone(), + tools: (!tools.is_empty()).then_some(tools), + parallel_tool_calls: false, + }; + let response = self.responses_client.create_response(request).await?; + + previous_response_id = response.id.clone(); + + let agent_message = response.response_text(); + if !agent_message.is_empty() { + event_sink.emit(&HarnessEvent::AgentMessageDelta { + turn_id: turn_id.to_string(), + delta: agent_message.clone(), + })?; + event_sink.emit(&HarnessEvent::AgentMessage { + turn_id: turn_id.to_string(), + message: agent_message.clone(), + })?; + last_agent_message = Some(agent_message); + } + + let function_calls = response.function_calls()?; + if function_calls.is_empty() { + return Ok(last_agent_message.unwrap_or_else(|| { + "Responses API returned no assistant message.".to_string() + })); + } + + let response_id = previous_response_id.clone().ok_or_else(|| { + HarnessError::new("Responses API omitted response.id for a tool-calling turn") + })?; + + let mut tool_outputs = Vec::with_capacity(function_calls.len()); + for function_call in function_calls { + event_sink.emit(&HarnessEvent::ToolCallStarted { + turn_id: turn_id.to_string(), + response_id: response_id.clone(), + call_id: function_call.call_id.clone(), + name: function_call.name.clone(), + arguments: function_call.arguments.clone(), + })?; + + let output = self.tool_executor.execute(&function_call).await?; + event_sink.emit(&HarnessEvent::ToolCallCompleted { + turn_id: turn_id.to_string(), + response_id: response_id.clone(), + call_id: function_call.call_id.clone(), + name: function_call.name.clone(), + output: output.clone(), + })?; + tool_outputs.push(tool_output_item(&function_call.call_id, output)); + } + + input = Value::Array(tool_outputs); + + if round + 1 == self.config.max_tool_rounds { + return Err(HarnessError::new( + "turn exceeded the browser tool-round limit", + )); + } + } + + Err(HarnessError::new("browser turn loop exited unexpectedly")) + } +} + +#[cfg(test)] +mod tests { + use super::EmbeddedHarness; + use super::EventSink; + use super::HarnessConfig; + use super::ResponsesClient; + use super::ToolExecutor; + use crate::HarnessError; + use crate::responses::HarnessEvent; + use crate::responses::ResponsesFunctionCall; + use crate::responses::ResponsesRequest; + use crate::responses::ResponsesResponse; + use crate::responses::ResponsesTool; + use async_trait::async_trait; + use pretty_assertions::assert_eq; + use std::cell::RefCell; + use std::collections::VecDeque; + + struct RecordingEventSink { + events: RefCell>, + } + + impl RecordingEventSink { + fn new() -> Self { + Self { + events: RefCell::new(Vec::new()), + } + } + + fn events(&self) -> Vec { + self.events.borrow().clone() + } + } + + impl EventSink for RecordingEventSink { + fn emit(&self, event: &HarnessEvent) -> Result<(), HarnessError> { + self.events.borrow_mut().push(event.clone()); + Ok(()) + } + } + + struct FakeResponsesClient { + responses: RefCell>, + requests: RefCell>, + } + + impl FakeResponsesClient { + fn new(responses: Vec) -> Self { + Self { + responses: RefCell::new(responses.into()), + requests: RefCell::new(Vec::new()), + } + } + } + + #[async_trait(?Send)] + impl ResponsesClient for FakeResponsesClient { + async fn create_response( + &self, + request: ResponsesRequest, + ) -> Result { + self.requests.borrow_mut().push(request); + self.responses + .borrow_mut() + .pop_front() + .ok_or_else(|| HarnessError::new("no fake response available")) + } + } + + struct FakeToolExecutor; + + #[async_trait(?Send)] + impl ToolExecutor for FakeToolExecutor { + fn tools(&self) -> Vec { + vec![ResponsesTool::exec_js()] + } + + async fn execute( + &self, + function_call: &ResponsesFunctionCall, + ) -> Result { + assert_eq!(function_call.name, "exec_js"); + Ok("Hello, world!".to_string()) + } + } + + #[tokio::test] + async fn embedded_harness_completes_tool_turn() { + let responses = vec![ + serde_json::from_str( + r#"{ + "id": "resp_1", + "output": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "exec_js", + "arguments": "{\"code\":\"console.log('Hello, world!')\"}" + } + ] + }"#, + ) + .expect("response should deserialize"), + serde_json::from_str( + r#"{ + "id": "resp_2", + "output_text": "Done." + }"#, + ) + .expect("response should deserialize"), + ]; + let client = FakeResponsesClient::new(responses); + let tool_executor = FakeToolExecutor; + let sink = RecordingEventSink::new(); + let mut harness = EmbeddedHarness::new(HarnessConfig::default(), client, tool_executor); + + let result = harness + .submit_turn("write hello world", &sink) + .await + .expect("turn should succeed"); + + assert_eq!(result, "Done."); + assert_eq!( + sink.events(), + vec![ + HarnessEvent::TurnStarted { + turn_id: "browser-turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: "default".to_string(), + }, + HarnessEvent::UserMessage { + turn_id: "browser-turn-1".to_string(), + message: "write hello world".to_string(), + }, + HarnessEvent::ToolCallStarted { + turn_id: "browser-turn-1".to_string(), + response_id: "resp_1".to_string(), + call_id: "call_1".to_string(), + name: "exec_js".to_string(), + arguments: r#"{"code":"console.log('Hello, world!')"}"#.to_string(), + }, + HarnessEvent::ToolCallCompleted { + turn_id: "browser-turn-1".to_string(), + response_id: "resp_1".to_string(), + call_id: "call_1".to_string(), + name: "exec_js".to_string(), + output: "Hello, world!".to_string(), + }, + HarnessEvent::AgentMessageDelta { + turn_id: "browser-turn-1".to_string(), + delta: "Done.".to_string(), + }, + HarnessEvent::AgentMessage { + turn_id: "browser-turn-1".to_string(), + message: "Done.".to_string(), + }, + HarnessEvent::TurnComplete { + turn_id: "browser-turn-1".to_string(), + last_agent_message: Some("Done.".to_string()), + }, + ] + ); + } +} diff --git a/codex-rs/wasm-harness/src/lib.rs b/codex-rs/wasm-harness/src/lib.rs index 40459c693e..b69d3d9575 100644 --- a/codex-rs/wasm-harness/src/lib.rs +++ b/codex-rs/wasm-harness/src/lib.rs @@ -1,611 +1,18 @@ -//! Browser-facing prototype facade for a future Codex WASM harness. -//! -//! This crate intentionally starts outside `codex-core`: the first milestone is -//! a working browser boundary that can run a minimal Codex-shaped turn loop in -//! the browser. The loop in this crate now uses the real Responses API and can -//! execute a browser-provided code tool callback, but it still does not call -//! `codex-core::run_turn` or `RegularTask::run`. +mod browser; +mod error; +mod harness; +mod responses; -use js_sys::Function; -use js_sys::Promise; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use wasm_bindgen::JsCast; -use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; -use web_sys::Headers; -use web_sys::RequestInit; -use web_sys::RequestMode; -use web_sys::Response; - -const BASE_INSTRUCTIONS: &str = include_str!("../../core/prompt.md"); -const BROWSER_TOOLING_NOTE: &str = concat!( - "Browser prototype note:\n", - "- Only one host tool is available: `exec_js`.\n", - "- `exec_js` runs JavaScript inside a browser-managed sandbox and returns stdout-like text.\n", - "- Native shell, filesystem, MCP, and plugin tools are unavailable in this prototype." -); -const RESPONSES_API_URL: &str = "https://api.openai.com/v1/responses"; -const RESPONSES_MODEL: &str = "gpt-5.1"; -const MAX_TOOL_ROUNDS: usize = 8; -const EXEC_JS_TOOL_NAME: &str = "exec_js"; - -/// Browser entrypoint for the prototype harness. -#[wasm_bindgen] -pub struct BrowserCodex { - api_key: String, - next_turn_id: u32, - code_executor: Option, -} - -#[wasm_bindgen] -impl BrowserCodex { - /// Creates a new browser harness. - /// - /// `api_key` may be empty. When it is empty, the harness uses a - /// deterministic local demo response. When it is present, the harness makes - /// browser `fetch` calls to the Responses API from Rust/WASM. - #[wasm_bindgen(constructor)] - #[must_use] - pub fn new(api_key: String) -> Self { - Self { - api_key, - next_turn_id: 0, - code_executor: None, - } - } - - /// Updates the API key used by future turns. - pub fn set_api_key(&mut self, api_key: String) { - self.api_key = api_key; - } - - /// Registers the JavaScript callback used for the `exec_js` tool. - pub fn set_code_executor(&mut self, executor: Function) { - self.code_executor = Some(executor); - } - - /// Clears the browser-side `exec_js` tool callback. - pub fn clear_code_executor(&mut self) { - self.code_executor = None; - } - - /// Submits one browser turn and calls `on_event` for every emitted event. - /// - /// The method resolves after `turn_complete` has been emitted. This mirrors - /// Codex's event-driven shape rather than exposing a `prompt -> string` - /// shortcut. - pub async fn submit_turn( - &mut self, - prompt: String, - on_event: Function, - ) -> Result { - self.next_turn_id += 1; - let turn_id = format!("browser-turn-{}", self.next_turn_id); - - emit_event( - &on_event, - &HarnessEvent::TurnStarted { - turn_id: turn_id.clone(), - model_context_window: None, - collaboration_mode_kind: "default", - }, - )?; - emit_event( - &on_event, - &HarnessEvent::UserMessage { - turn_id: turn_id.clone(), - message: prompt.clone(), - }, - )?; - - let agent_message = if self.api_key.trim().is_empty() { - default_demo_response(&prompt) - } else { - self.run_turn_with_responses_api(&turn_id, &prompt, &on_event) - .await? - }; - - emit_event( - &on_event, - &HarnessEvent::TurnComplete { - turn_id, - last_agent_message: Some(agent_message.clone()), - }, - )?; - - Ok(JsValue::from_str(&agent_message)) - } -} - -impl BrowserCodex { - async fn run_turn_with_responses_api( - &self, - turn_id: &str, - prompt: &str, - on_event: &Function, - ) -> Result { - let mut previous_response_id: Option = None; - let mut input = Value::String(prompt.to_string()); - let instructions = build_browser_instructions(); - let mut last_agent_message: Option = None; - - for round in 0..MAX_TOOL_ROUNDS { - let response = fetch_responses_api( - self.api_key.trim(), - ResponsesRequestBody { - model: RESPONSES_MODEL, - instructions: instructions.clone(), - input, - previous_response_id: previous_response_id.clone(), - tools: self.responses_tools(), - parallel_tool_calls: false, - }, - ) - .await?; - - previous_response_id = response.id.clone(); - - let agent_message = extract_response_text(&response); - if !agent_message.is_empty() { - emit_event( - on_event, - &HarnessEvent::AgentMessageDelta { - turn_id: turn_id.to_string(), - delta: agent_message.clone(), - }, - )?; - emit_event( - on_event, - &HarnessEvent::AgentMessage { - turn_id: turn_id.to_string(), - message: agent_message.clone(), - }, - )?; - last_agent_message = Some(agent_message); - } - - let function_calls = extract_function_calls(&response)?; - if function_calls.is_empty() { - return Ok(last_agent_message.unwrap_or_else(|| { - "Responses API returned no assistant message.".to_string() - })); - } - - let response_id = previous_response_id.clone().ok_or_else(|| { - js_error("Responses API omitted response.id for a tool-calling turn") - })?; - let mut tool_outputs = Vec::with_capacity(function_calls.len()); - for function_call in function_calls { - emit_event( - on_event, - &HarnessEvent::ToolCallStarted { - turn_id: turn_id.to_string(), - response_id: response_id.clone(), - call_id: function_call.call_id.clone(), - name: function_call.name.clone(), - arguments: function_call.arguments.clone(), - }, - )?; - - let output = self.execute_function_call(&function_call).await; - match output { - Ok(output) => { - emit_event( - on_event, - &HarnessEvent::ToolCallCompleted { - turn_id: turn_id.to_string(), - response_id: response_id.clone(), - call_id: function_call.call_id.clone(), - name: function_call.name.clone(), - output: output.clone(), - }, - )?; - tool_outputs.push(tool_output_item(&function_call.call_id, output)); - } - Err(err) => { - emit_event( - on_event, - &HarnessEvent::TurnError { - turn_id: turn_id.to_string(), - message: err.as_string().unwrap_or_else(|| { - "tool execution failed with a non-string JavaScript error" - .to_string() - }), - }, - )?; - return Err(err); - } - } - } - - input = Value::Array(tool_outputs); - - if round + 1 == MAX_TOOL_ROUNDS { - return Err(js_error("turn exceeded the browser tool-round limit")); - } - } - - Err(js_error("browser turn loop exited unexpectedly")) - } - - fn responses_tools(&self) -> Option> { - self.code_executor - .as_ref() - .map(|_| vec![ResponsesTool::exec_js()]) - } - - async fn execute_function_call( - &self, - function_call: &ResponsesFunctionCall, - ) -> Result { - if function_call.name != EXEC_JS_TOOL_NAME { - return Err(js_error(format!( - "browser prototype does not implement tool `{}`", - function_call.name - ))); - } - - let executor = self.code_executor.as_ref().ok_or_else(|| { - js_error("`exec_js` was requested but no browser executor is registered") - })?; - let args: ExecJsArguments = - serde_json::from_str(&function_call.arguments).map_err(js_error)?; - let value = executor.call1(&JsValue::NULL, &JsValue::from_str(&args.code))?; - let value = await_possible_promise(value).await?; - js_value_to_string(value) - } -} - -#[derive(Debug, Serialize)] -struct ResponsesRequestBody { - model: &'static str, - instructions: String, - input: Value, - #[serde(skip_serializing_if = "Option::is_none")] - previous_response_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - parallel_tool_calls: bool, -} - -#[derive(Debug, Serialize)] -struct ResponsesTool { - #[serde(rename = "type")] - kind: &'static str, - name: &'static str, - description: &'static str, - parameters: Value, -} - -impl ResponsesTool { - fn exec_js() -> Self { - Self { - kind: "function", - name: EXEC_JS_TOOL_NAME, - description: "Execute JavaScript inside the browser sandbox and return the textual result.", - parameters: serde_json::json!({ - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "JavaScript source code to execute in the browser sandbox." - } - }, - "required": ["code"], - "additionalProperties": false - }), - } - } -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum HarnessEvent<'a> { - TurnStarted { - turn_id: String, - model_context_window: Option, - collaboration_mode_kind: &'a str, - }, - UserMessage { - turn_id: String, - message: String, - }, - AgentMessageDelta { - turn_id: String, - delta: String, - }, - AgentMessage { - turn_id: String, - message: String, - }, - ToolCallStarted { - turn_id: String, - response_id: String, - call_id: String, - name: String, - arguments: String, - }, - ToolCallCompleted { - turn_id: String, - response_id: String, - call_id: String, - name: String, - output: String, - }, - TurnError { - turn_id: String, - message: String, - }, - TurnComplete { - turn_id: String, - last_agent_message: Option, - }, -} - -#[derive(Debug, Deserialize)] -struct ResponsesBody { - id: Option, - output_text: Option, - output: Option>, - error: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponsesError { - message: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponsesOutputItem { - #[serde(rename = "type")] - kind: String, - call_id: Option, - name: Option, - arguments: Option, - content: Option>, -} - -#[derive(Debug, Deserialize)] -struct ResponsesContentItem { - text: Option, - output_text: Option, -} - -#[derive(Debug)] -struct ResponsesFunctionCall { - call_id: String, - name: String, - arguments: String, -} - -#[derive(Debug, Deserialize)] -struct ExecJsArguments { - code: String, -} - -async fn fetch_responses_api( - api_key: &str, - body: ResponsesRequestBody, -) -> Result { - let body = serde_json::to_string(&body).map_err(js_error)?; - - let headers = Headers::new()?; - headers.append("Authorization", &format!("Bearer {api_key}"))?; - headers.append("Content-Type", "application/json")?; - - let request = RequestInit::new(); - request.set_method("POST"); - request.set_mode(RequestMode::Cors); - request.set_headers(&headers); - request.set_body(&JsValue::from_str(&body)); - - let window = web_sys::window().ok_or_else(|| js_error("window is unavailable"))?; - let response_value = - JsFuture::from(window.fetch_with_str_and_init(RESPONSES_API_URL, &request)) - .await - .map_err(js_fetch_error)?; - let response: Response = response_value.dyn_into()?; - let status = response.status(); - let ok = response.ok(); - let json = JsFuture::from(response.json()?) - .await - .map_err(js_fetch_error)?; - let response_body = parse_response_body(json)?; - - if !ok { - let message = response_body - .error - .and_then(|err| err.message) - .unwrap_or_else(|| format!("Responses API returned {status}")); - return Err(js_error(message)); - } - - Ok(response_body) -} - -fn parse_response_body(value: JsValue) -> Result { - let json = js_sys::JSON::stringify(&value)? - .as_string() - .ok_or_else(|| js_error("Responses API returned non-JSON output"))?; - serde_json::from_str(&json).map_err(js_error) -} - -fn extract_response_text(response: &ResponsesBody) -> String { - if let Some(output_text) = &response.output_text - && !output_text.is_empty() - { - return output_text.clone(); - } - - let mut chunks = Vec::new(); - for item in response.output.as_ref().into_iter().flatten() { - for content in item.content.as_ref().into_iter().flatten() { - if let Some(text) = &content.text { - chunks.push(text.clone()); - } else if let Some(output_text) = &content.output_text { - chunks.push(output_text.clone()); - } - } - } - - chunks.join("\n") -} - -fn extract_function_calls(response: &ResponsesBody) -> Result, JsValue> { - let mut function_calls = Vec::new(); - for item in response.output.as_ref().into_iter().flatten() { - if item.kind != "function_call" { - continue; - } - let call_id = item - .call_id - .clone() - .ok_or_else(|| js_error("Responses API function_call item omitted call_id"))?; - let name = item - .name - .clone() - .ok_or_else(|| js_error("Responses API function_call item omitted name"))?; - let arguments = item - .arguments - .clone() - .ok_or_else(|| js_error("Responses API function_call item omitted arguments"))?; - function_calls.push(ResponsesFunctionCall { - call_id, - name, - arguments, - }); - } - Ok(function_calls) -} - -fn tool_output_item(call_id: &str, output: String) -> Value { - serde_json::json!({ - "type": "function_call_output", - "call_id": call_id, - "output": output, - }) -} - -fn build_browser_instructions() -> String { - format!("{BASE_INSTRUCTIONS}\n\n{BROWSER_TOOLING_NOTE}") -} - -async fn await_possible_promise(value: JsValue) -> Result { - if let Ok(promise) = value.clone().dyn_into::() { - JsFuture::from(promise).await - } else { - Ok(value) - } -} - -fn js_value_to_string(value: JsValue) -> Result { - if let Some(text) = value.as_string() { - return Ok(text); - } - - if value.is_undefined() || value.is_null() { - return Ok(String::new()); - } - - let json = js_sys::JSON::stringify(&value)?; - Ok(json - .as_string() - .unwrap_or_else(|| "[non-string value]".to_string())) -} - -fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsValue> { - let json = serde_json::to_string(event).map_err(js_error)?; - let value = js_sys::JSON::parse(&json)?; - on_event.call1(&JsValue::NULL, &value)?; - Ok(()) -} - -fn default_demo_response(prompt: &str) -> String { - if prompt.to_ascii_lowercase().contains("hello world") { - "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```" - .to_string() - } else { - format!("Demo mode is active because no API key was provided. Prompt received:\n\n{prompt}") - } -} - -fn js_error(error: impl ToString) -> JsValue { - JsValue::from_str(&error.to_string()) -} - -fn js_fetch_error(error: JsValue) -> JsValue { - JsValue::from_str(&format!( - "browser fetch failed: {}", - js_value_to_string_lossy(&error) - )) -} - -fn js_value_to_string_lossy(value: &JsValue) -> String { - if let Some(text) = value.as_string() { - return text; - } - - js_sys::JSON::stringify(value) - .ok() - .and_then(|text| text.as_string()) - .unwrap_or_else(|| "[non-string javascript error]".to_string()) -} - -#[cfg(test)] -mod tests { - use super::ResponsesBody; - use super::build_browser_instructions; - use super::extract_function_calls; - use super::extract_response_text; - use pretty_assertions::assert_eq; - - #[test] - fn browser_instructions_append_browser_note() { - let instructions = build_browser_instructions(); - assert!(instructions.contains("Only one host tool is available: `exec_js`")); - assert!(instructions.contains("You are a coding agent running in the Codex CLI")); - } - - #[test] - fn extracts_output_text_when_present() { - let response: ResponsesBody = serde_json::from_str( - r#"{ - "id": "resp_123", - "output_text": "final answer", - "output": [] - }"#, - ) - .expect("response should deserialize"); - assert_eq!(extract_response_text(&response), "final answer"); - } - - #[test] - fn extracts_function_calls_from_output_items() { - let response: ResponsesBody = serde_json::from_str( - r#"{ - "id": "resp_123", - "output": [ - { - "type": "function_call", - "call_id": "call_123", - "name": "exec_js", - "arguments": "{\"code\":\"console.log('hi')\"}" - } - ] - }"#, - ) - .expect("response should deserialize"); - - let function_calls = - extract_function_calls(&response).expect("function calls should parse"); - assert_eq!(function_calls.len(), 1); - assert_eq!(function_calls[0].call_id, "call_123"); - assert_eq!(function_calls[0].name, "exec_js"); - assert_eq!( - function_calls[0].arguments, - r#"{"code":"console.log('hi')"}"# - ); - } -} +pub use browser::BrowserCodex; +pub use error::HarnessError; +pub use harness::EmbeddedHarness; +pub use harness::EventSink; +pub use harness::HarnessConfig; +pub use harness::ResponsesClient; +pub use harness::ToolExecutor; +pub use responses::EXEC_JS_TOOL_NAME; +pub use responses::HarnessEvent; +pub use responses::ResponsesFunctionCall; +pub use responses::ResponsesRequest; +pub use responses::ResponsesResponse; +pub use responses::ResponsesTool; diff --git a/codex-rs/wasm-harness/src/responses.rs b/codex-rs/wasm-harness/src/responses.rs new file mode 100644 index 0000000000..07c1c39c4d --- /dev/null +++ b/codex-rs/wasm-harness/src/responses.rs @@ -0,0 +1,276 @@ +use crate::HarnessError; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +const BASE_INSTRUCTIONS: &str = include_str!("../../core/prompt.md"); +const BROWSER_TOOLING_NOTE: &str = concat!( + "Browser prototype note:\n", + "- Only one host tool is available: `exec_js`.\n", + "- `exec_js` runs JavaScript inside a browser-managed sandbox and returns stdout-like text.\n", + "- Native shell, filesystem, MCP, and plugin tools are unavailable in this prototype." +); + +pub const EXEC_JS_TOOL_NAME: &str = "exec_js"; + +#[derive(Clone, Debug, Serialize)] +pub struct ResponsesRequest { + pub model: String, + pub instructions: String, + pub input: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + pub parallel_tool_calls: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct ResponsesTool { + #[serde(rename = "type")] + pub kind: String, + pub name: String, + pub description: String, + pub parameters: Value, +} + +impl ResponsesTool { + #[must_use] + pub fn function( + name: impl Into, + description: impl Into, + parameters: Value, + ) -> Self { + Self { + kind: "function".to_string(), + name: name.into(), + description: description.into(), + parameters, + } + } + + #[must_use] + pub fn exec_js() -> Self { + Self::function( + EXEC_JS_TOOL_NAME, + "Execute JavaScript inside the browser sandbox and return the textual result.", + serde_json::json!({ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "JavaScript source code to execute in the browser sandbox." + } + }, + "required": ["code"], + "additionalProperties": false + }), + ) + } +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HarnessEvent { + TurnStarted { + turn_id: String, + model_context_window: Option, + collaboration_mode_kind: String, + }, + UserMessage { + turn_id: String, + message: String, + }, + AgentMessageDelta { + turn_id: String, + delta: String, + }, + AgentMessage { + turn_id: String, + message: String, + }, + ToolCallStarted { + turn_id: String, + response_id: String, + call_id: String, + name: String, + arguments: String, + }, + ToolCallCompleted { + turn_id: String, + response_id: String, + call_id: String, + name: String, + output: String, + }, + TurnError { + turn_id: String, + message: String, + }, + TurnComplete { + turn_id: String, + last_agent_message: Option, + }, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ResponsesResponse { + pub id: Option, + pub output_text: Option, + pub output: Option>, + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ResponsesError { + pub message: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ResponsesOutputItem { + #[serde(rename = "type")] + pub kind: String, + pub call_id: Option, + pub name: Option, + pub arguments: Option, + pub content: Option>, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ResponsesContentItem { + pub text: Option, + pub output_text: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResponsesFunctionCall { + pub call_id: String, + pub name: String, + pub arguments: String, +} + +impl ResponsesResponse { + #[must_use] + pub fn response_text(&self) -> String { + if let Some(output_text) = &self.output_text + && !output_text.is_empty() + { + return output_text.clone(); + } + + let mut chunks = Vec::new(); + for item in self.output.as_ref().into_iter().flatten() { + for content in item.content.as_ref().into_iter().flatten() { + if let Some(text) = &content.text { + chunks.push(text.clone()); + } else if let Some(output_text) = &content.output_text { + chunks.push(output_text.clone()); + } + } + } + + chunks.join("\n") + } + + pub fn function_calls(&self) -> Result, HarnessError> { + let mut function_calls = Vec::new(); + for item in self.output.as_ref().into_iter().flatten() { + if item.kind != "function_call" { + continue; + } + let call_id = item.call_id.clone().ok_or_else(|| { + HarnessError::new("Responses API function_call item omitted call_id") + })?; + let name = item.name.clone().ok_or_else(|| { + HarnessError::new("Responses API function_call item omitted name") + })?; + let arguments = item.arguments.clone().ok_or_else(|| { + HarnessError::new("Responses API function_call item omitted arguments") + })?; + function_calls.push(ResponsesFunctionCall { + call_id, + name, + arguments, + }); + } + Ok(function_calls) + } +} + +#[must_use] +pub fn tool_output_item(call_id: &str, output: String) -> Value { + serde_json::json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output, + }) +} + +#[must_use] +pub fn build_browser_instructions() -> String { + format!("{BASE_INSTRUCTIONS}\n\n{BROWSER_TOOLING_NOTE}") +} + +#[cfg(test)] +mod tests { + use super::ResponsesResponse; + use super::ResponsesTool; + use super::build_browser_instructions; + use pretty_assertions::assert_eq; + + #[test] + fn browser_instructions_append_browser_note() { + let instructions = build_browser_instructions(); + assert!(instructions.contains("Only one host tool is available: `exec_js`")); + assert!(instructions.contains("You are a coding agent running in the Codex CLI")); + } + + #[test] + fn extracts_output_text_when_present() { + let response: ResponsesResponse = serde_json::from_str( + r#"{ + "id": "resp_123", + "output_text": "final answer", + "output": [] + }"#, + ) + .expect("response should deserialize"); + assert_eq!(response.response_text(), "final answer"); + } + + #[test] + fn extracts_function_calls_from_output_items() { + let response: ResponsesResponse = serde_json::from_str( + r#"{ + "id": "resp_123", + "output": [ + { + "type": "function_call", + "call_id": "call_123", + "name": "exec_js", + "arguments": "{\"code\":\"console.log('hi')\"}" + } + ] + }"#, + ) + .expect("response should deserialize"); + + let function_calls = response + .function_calls() + .expect("function calls should parse"); + assert_eq!(function_calls.len(), 1); + assert_eq!(function_calls[0].call_id, "call_123"); + assert_eq!(function_calls[0].name, "exec_js"); + assert_eq!( + function_calls[0].arguments, + r#"{"code":"console.log('hi')"}"# + ); + } + + #[test] + fn exec_tool_schema_is_function_tool() { + let tool = ResponsesTool::exec_js(); + assert_eq!(tool.kind, "function"); + assert_eq!(tool.name, "exec_js"); + } +}