Skip to content

Commit 987c14d

Browse files
committed
Add multimodal outputs to js_repl polling
git-stack-id: fjord/js_repl_seq---4hwa73o5rjfj-v git-stack-title: Add multimodal outputs to js_repl polling
1 parent 8608f60 commit 987c14d

File tree

6 files changed

+628
-7
lines changed

6 files changed

+628
-7
lines changed

codex-rs/core/src/project_doc.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
6060

6161
if config.features.enabled(Feature::JsReplPolling) {
6262
section.push_str("- Polling mode is session-based: call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get `exec_id` and `session_id`; provide `session_id=<id>` in later `js_repl` calls to reuse that session state. Omit `session_id` to create a new polling session, and note that unknown `session_id` values fail. Use space-separated pragma arguments.\n");
63-
section.push_str("- Use `js_repl_poll` with `exec_id` until `status` is `completed` or `error`; if a poll returns `status: running`, keep polling the same `exec_id` even if the logs or output already look complete. Poll responses also include `session_id`.\n");
63+
section.push_str("- Use `js_repl_poll` with `exec_id` until `status` is `completed` or `error`; if a poll returns `status: running`, keep polling the same `exec_id` even if the logs or output already look complete. Poll responses also include `session_id`, and completed polls can also include nested multimodal tool output after the JSON status item.\n");
6464
section.push_str("- Use `js_repl_reset({\"session_id\":\"...\"})` to stop one polling session (including any running exec), or `js_repl_reset({})` to reset all js_repl kernels.\n");
6565
section.push_str("- `js_repl_poll` must not be called before a successful `js_repl` polling submission returns an `exec_id`.\n");
6666
section.push_str("- In polling mode, `timeout_ms` is not supported on `js_repl`; use `js_repl_poll` `yield_time_ms` to control poll wait duration. Omitted values, or values below `5000`, wait up to 5 seconds before returning if nothing new arrives.\n");
@@ -512,7 +512,7 @@ mod tests {
512512
let res = get_user_instructions(&cfg, None)
513513
.await
514514
.expect("js_repl instructions expected");
515-
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Polling mode is session-based: call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get `exec_id` and `session_id`; provide `session_id=<id>` in later `js_repl` calls to reuse that session state. Omit `session_id` to create a new polling session, and note that unknown `session_id` values fail. Use space-separated pragma arguments.\n- Use `js_repl_poll` with `exec_id` until `status` is `completed` or `error`; if a poll returns `status: running`, keep polling the same `exec_id` even if the logs or output already look complete. Poll responses also include `session_id`.\n- Use `js_repl_reset({\"session_id\":\"...\"})` to stop one polling session (including any running exec), or `js_repl_reset({})` to reset all js_repl kernels.\n- `js_repl_poll` must not be called before a successful `js_repl` polling submission returns an `exec_id`.\n- In polling mode, `timeout_ms` is not supported on `js_repl`; use `js_repl_poll` `yield_time_ms` to control poll wait duration. Omitted values, or values below `5000`, wait up to 5 seconds before returning if nothing new arrives.\n- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`.";
515+
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Polling mode is session-based: call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get `exec_id` and `session_id`; provide `session_id=<id>` in later `js_repl` calls to reuse that session state. Omit `session_id` to create a new polling session, and note that unknown `session_id` values fail. Use space-separated pragma arguments.\n- Use `js_repl_poll` with `exec_id` until `status` is `completed` or `error`; if a poll returns `status: running`, keep polling the same `exec_id` even if the logs or output already look complete. Poll responses also include `session_id`, and completed polls can also include nested multimodal tool output after the JSON status item.\n- Use `js_repl_reset({\"session_id\":\"...\"})` to stop one polling session (including any running exec), or `js_repl_reset({})` to reset all js_repl kernels.\n- `js_repl_poll` must not be called before a successful `js_repl` polling submission returns an `exec_id`.\n- In polling mode, `timeout_ms` is not supported on `js_repl`; use `js_repl_poll` `yield_time_ms` to control poll wait duration. Omitted values, or values below `5000`, wait up to 5 seconds before returning if nothing new arrives.\n- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`.";
516516
assert_eq!(res, expected);
517517
}
518518

codex-rs/core/src/tools/handlers/js_repl.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,16 @@ fn format_poll_output(result: &JsExecPollResult) -> Result<JsReplPollOutput, Fun
367367
FunctionCallError::Fatal(format!("failed to serialize js_repl poll result: {err}"))
368368
})?;
369369

370-
Ok(JsReplPollOutput {
371-
body: FunctionCallOutputBody::Text(content),
372-
})
370+
let body = if result.content_items.is_empty() {
371+
FunctionCallOutputBody::Text(content)
372+
} else {
373+
let mut items = Vec::with_capacity(result.content_items.len() + 1);
374+
items.push(FunctionCallOutputContentItem::InputText { text: content });
375+
items.extend(result.content_items.clone());
376+
FunctionCallOutputBody::ContentItems(items)
377+
};
378+
379+
Ok(JsReplPollOutput { body })
373380
}
374381

375382
fn parse_freeform_args(
@@ -532,6 +539,7 @@ mod tests {
532539
use crate::tools::js_repl::JS_REPL_TIMEOUT_ERROR_MESSAGE;
533540
use crate::tools::js_repl::JsExecPollResult;
534541
use codex_protocol::models::FunctionCallOutputBody;
542+
use codex_protocol::models::FunctionCallOutputContentItem;
535543
use pretty_assertions::assert_eq;
536544
use serde_json::json;
537545

@@ -661,6 +669,7 @@ mod tests {
661669
session_id: "session-1".to_string(),
662670
logs: vec!["line 1".to_string(), "line 2".to_string()],
663671
output: None,
672+
content_items: Vec::new(),
664673
error: None,
665674
done: false,
666675
};
@@ -690,6 +699,7 @@ mod tests {
690699
session_id: "session-1".to_string(),
691700
logs: Vec::new(),
692701
output: Some(String::new()),
702+
content_items: Vec::new(),
693703
error: None,
694704
done: true,
695705
};
@@ -712,6 +722,44 @@ mod tests {
712722
);
713723
}
714724

725+
#[test]
726+
fn format_poll_output_serializes_multimodal_content_items() {
727+
let result = JsExecPollResult {
728+
exec_id: "exec-1".to_string(),
729+
session_id: "session-1".to_string(),
730+
logs: Vec::new(),
731+
output: Some("stdout".to_string()),
732+
content_items: vec![FunctionCallOutputContentItem::InputImage {
733+
image_url: "data:image/png;base64,abc".to_string(),
734+
}],
735+
error: None,
736+
done: true,
737+
};
738+
let output = format_poll_output(&result).expect("format poll output");
739+
let FunctionCallOutputBody::ContentItems(items) = output.body else {
740+
panic!("expected content item poll output");
741+
};
742+
assert_eq!(
743+
items,
744+
vec![
745+
FunctionCallOutputContentItem::InputText {
746+
text: json!({
747+
"exec_id": "exec-1",
748+
"session_id": "session-1",
749+
"status": "completed",
750+
"logs": null,
751+
"output": "stdout",
752+
"error": null,
753+
})
754+
.to_string(),
755+
},
756+
FunctionCallOutputContentItem::InputImage {
757+
image_url: "data:image/png;base64,abc".to_string(),
758+
},
759+
]
760+
);
761+
}
762+
715763
#[test]
716764
fn js_repl_poll_args_reject_unknown_fields() {
717765
let err = serde_json::from_str::<super::JsReplPollArgs>(

0 commit comments

Comments
 (0)