mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
refactor: split wasm harness into reusable library surface
Expose a pure Rust EmbeddedHarness API in codex-wasm-harness so downstream browser apps can depend on the crate directly without going through the demo-specific wasm_bindgen wrapper. Introduce trait seams for Responses transport, tool execution, and event delivery, move shared response/event types into dedicated modules, and keep BrowserCodex as a thin compatibility adapter so the existing browser demo continues to build and run unchanged.
This commit is contained in:
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
271
codex-rs/wasm-harness/src/browser.rs
Normal file
271
codex-rs/wasm-harness/src/browser.rs
Normal file
@@ -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<ResponsesResponse, HarnessError> {
|
||||
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<Function>,
|
||||
}
|
||||
|
||||
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<ResponsesTool> {
|
||||
self.code_executor
|
||||
.as_ref()
|
||||
.map(|_| vec![ResponsesTool::exec_js()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn execute(&self, function_call: &ResponsesFunctionCall) -> Result<String, HarnessError> {
|
||||
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<BrowserResponsesClient, BrowserToolExecutor>,
|
||||
}
|
||||
|
||||
#[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<JsValue, JsValue> {
|
||||
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<ResponsesResponse, HarnessError> {
|
||||
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<JsValue, HarnessError> {
|
||||
if let Ok(promise) = value.clone().dyn_into::<Promise>() {
|
||||
JsFuture::from(promise).await.map_err(js_exception)
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn js_value_to_string(value: JsValue) -> Result<String, HarnessError> {
|
||||
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::<String>(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,
|
||||
}
|
||||
}
|
||||
43
codex-rs/wasm-harness/src/error.rs
Normal file
43
codex-rs/wasm-harness/src/error.rs
Normal file
@@ -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<String>) -> 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<String> for HarnessError {
|
||||
fn from(value: String) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for HarnessError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::new(value.to_string())
|
||||
}
|
||||
}
|
||||
374
codex-rs/wasm-harness/src/harness.rs
Normal file
374
codex-rs/wasm-harness/src/harness.rs
Normal file
@@ -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<ResponsesResponse, HarnessError>;
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
pub trait ToolExecutor {
|
||||
fn tools(&self) -> Vec<ResponsesTool>;
|
||||
|
||||
async fn execute(&self, function_call: &ResponsesFunctionCall) -> Result<String, HarnessError>;
|
||||
}
|
||||
|
||||
pub struct EmbeddedHarness<C, T> {
|
||||
config: HarnessConfig,
|
||||
responses_client: C,
|
||||
tool_executor: T,
|
||||
next_turn_id: u32,
|
||||
}
|
||||
|
||||
impl<C, T> EmbeddedHarness<C, T> {
|
||||
#[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<C, T> EmbeddedHarness<C, T>
|
||||
where
|
||||
C: ResponsesClient,
|
||||
T: ToolExecutor,
|
||||
{
|
||||
pub async fn submit_turn<E: EventSink>(
|
||||
&mut self,
|
||||
prompt: impl Into<String>,
|
||||
event_sink: &E,
|
||||
) -> Result<String, HarnessError> {
|
||||
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<E: EventSink>(
|
||||
&self,
|
||||
turn_id: &str,
|
||||
prompt: &str,
|
||||
event_sink: &E,
|
||||
) -> Result<String, HarnessError> {
|
||||
let mut previous_response_id: Option<String> = None;
|
||||
let mut input = Value::String(prompt.to_string());
|
||||
let mut last_agent_message: Option<String> = 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<Vec<HarnessEvent>>,
|
||||
}
|
||||
|
||||
impl RecordingEventSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<HarnessEvent> {
|
||||
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<VecDeque<ResponsesResponse>>,
|
||||
requests: RefCell<Vec<ResponsesRequest>>,
|
||||
}
|
||||
|
||||
impl FakeResponsesClient {
|
||||
fn new(responses: Vec<ResponsesResponse>) -> 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<ResponsesResponse, HarnessError> {
|
||||
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<ResponsesTool> {
|
||||
vec![ResponsesTool::exec_js()]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
function_call: &ResponsesFunctionCall,
|
||||
) -> Result<String, HarnessError> {
|
||||
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()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Function>,
|
||||
}
|
||||
|
||||
#[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<JsValue, JsValue> {
|
||||
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<String, JsValue> {
|
||||
let mut previous_response_id: Option<String> = None;
|
||||
let mut input = Value::String(prompt.to_string());
|
||||
let instructions = build_browser_instructions();
|
||||
let mut last_agent_message: Option<String> = 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<Vec<ResponsesTool>> {
|
||||
self.code_executor
|
||||
.as_ref()
|
||||
.map(|_| vec![ResponsesTool::exec_js()])
|
||||
}
|
||||
|
||||
async fn execute_function_call(
|
||||
&self,
|
||||
function_call: &ResponsesFunctionCall,
|
||||
) -> Result<String, JsValue> {
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<ResponsesTool>>,
|
||||
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<i64>,
|
||||
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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesBody {
|
||||
id: Option<String>,
|
||||
output_text: Option<String>,
|
||||
output: Option<Vec<ResponsesOutputItem>>,
|
||||
error: Option<ResponsesError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesError {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesOutputItem {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
call_id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
content: Option<Vec<ResponsesContentItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesContentItem {
|
||||
text: Option<String>,
|
||||
output_text: Option<String>,
|
||||
}
|
||||
|
||||
#[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<ResponsesBody, JsValue> {
|
||||
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<ResponsesBody, JsValue> {
|
||||
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<Vec<ResponsesFunctionCall>, 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<JsValue, JsValue> {
|
||||
if let Ok(promise) = value.clone().dyn_into::<Promise>() {
|
||||
JsFuture::from(promise).await
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn js_value_to_string(value: JsValue) -> Result<String, JsValue> {
|
||||
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;
|
||||
|
||||
276
codex-rs/wasm-harness/src/responses.rs
Normal file
276
codex-rs/wasm-harness/src/responses.rs
Normal file
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ResponsesTool>>,
|
||||
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<String>,
|
||||
description: impl Into<String>,
|
||||
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<i64>,
|
||||
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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ResponsesResponse {
|
||||
pub id: Option<String>,
|
||||
pub output_text: Option<String>,
|
||||
pub output: Option<Vec<ResponsesOutputItem>>,
|
||||
pub error: Option<ResponsesError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ResponsesError {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ResponsesOutputItem {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub call_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
pub content: Option<Vec<ResponsesContentItem>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ResponsesContentItem {
|
||||
pub text: Option<String>,
|
||||
pub output_text: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Vec<ResponsesFunctionCall>, 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user