From 77d2a046447bdf6ddbe005f9b66f19924c54e228 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 1 Aug 2026 19:20:30 +0800 Subject: [PATCH 1/2] fix(codex): rebuild per-command cards from code-mode scripts Recent codex builds wrap every tool call in a JavaScript program executed as a single `exec` call. Replaying such a session showed one card per program -- named after the raw tool, bodied with the script source and a dump of chunk envelopes -- where the commands, patches, reads and searches it ran each used to have a card of their own. Only history was affected: the live path reads codex's own per-item stream and always showed them separately. The program is now read back into the calls it makes, and each call gets the card it had before code mode, with its output claimed from the matching chunk. Splitting only happens when the program provably prints one group of chunks per call in call order, including when codex collapses a long output stream into a single truncated blob. A program that does not prove that keeps a script card of its own -- a readable one, showing the source and how many tools it invoked -- rather than risk filing one command's output under another's name. A program that has not finished answers with a cell id, and its real return value arrives turns later as a `wait`. That value is folded back onto the cards the program already owns instead of landing as a separate card, so a long-running script no longer leaves a "Script running" placeholder followed by an unattached result. `wait` and `write_stdin` carry no command of their own, only the id of a background shell an earlier command left running, so they rendered as bare `wait` / `bash` cards over a parameter dump. Each is now titled by the command whose session it addresses, and one that only collects more output -- nearly all of them -- is folded into that command's card. Ones that send keystrokes or terminate keep a card, labelled with what they sent. When the session was never announced in a form the parser trusts, the card names the session id instead, and the command that started it now shows the same id beside its title so the two can be matched up. Tool results without a card of their own no longer arrive as one flat blob of JSON: they render as a collapsible tree, expanded as far as it stays readable, with a switch back to the raw text. This applies to every agent. A delegation started from inside a code-mode script also lost its button to open the child conversation, because the tool call id the child was recorded under is rewritten by code mode. It is now found by the task id both sides share. --- src-tauri/src/commands/conversations.rs | 153 +- src-tauri/src/parsers/codex.rs | 2386 ++++++++++++++++- src-tauri/src/parsers/codex_code_mode.rs | 2182 +++++++++++++++ src-tauri/src/parsers/mod.rs | 1 + src/components/ai-elements/json-tree.test.tsx | 115 + src/components/ai-elements/json-tree.tsx | 313 +++ src/components/ai-elements/tool.tsx | 13 +- .../message/codex-script-tool-card.test.tsx | 131 + .../message/content-parts-renderer.tsx | 163 +- .../message/shell-session-tool-card.test.tsx | 171 ++ src/i18n/messages/ar.json | 14 + src/i18n/messages/de.json | 14 + src/i18n/messages/en.json | 14 + src/i18n/messages/es.json | 14 + src/i18n/messages/fr.json | 14 + src/i18n/messages/ja.json | 14 + src/i18n/messages/ko.json | 14 + src/i18n/messages/pt.json | 14 + src/i18n/messages/zh-CN.json | 14 + src/i18n/messages/zh-TW.json | 14 + src/lib/adapters/tool-kind-classifier.ts | 7 +- src/lib/codex-code-mode.ts | 49 + src/lib/shell-session-tool.test.ts | 100 + src/lib/shell-session-tool.ts | 129 + src/lib/tool-call-normalization.ts | 14 +- 25 files changed, 5982 insertions(+), 85 deletions(-) create mode 100644 src-tauri/src/parsers/codex_code_mode.rs create mode 100644 src/components/ai-elements/json-tree.test.tsx create mode 100644 src/components/ai-elements/json-tree.tsx create mode 100644 src/components/message/codex-script-tool-card.test.tsx create mode 100644 src/components/message/shell-session-tool-card.test.tsx create mode 100644 src/lib/codex-code-mode.ts create mode 100644 src/lib/shell-session-tool.test.ts create mode 100644 src/lib/shell-session-tool.ts diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 5607aca625..ce5a89348d 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -874,13 +874,43 @@ fn build_historical_delegation_meta(child: &DbConversationSummary) -> serde_json serde_json::Value::Object(obj) } -/// Walk every `delegate_to_agent` ToolUse block in `turns` and, when its -/// `tool_use_id` matches a child conversation in `children`, set -/// `meta["codeg.delegation"]` to the DB-derived snapshot. Skips blocks -/// whose meta is already populated so the live-broker write (when present) -/// always wins. Tool-name match is by substring to cover the -/// MCP-prefixed (`mcp__codeg-mcp__delegate_to_agent`) and bare forms -/// the host may have emitted. +/// The broker-minted task id a `delegate_to_agent` result announces. Codex +/// persists the ack as prose (`Delegation successful. task_id=. Call +/// get_delegation_status …`); other hosts return `{"task_id":""}` — both +/// are covered by reading `task_id` followed by `=` or `:`. Mirrors the +/// frontend's `parseDelegateTaskId` (`lib/delegation-card.ts`). +fn parse_delegate_task_id(output: &str) -> Option { + let at = output.find("task_id")? + "task_id".len(); + let rest = output[at..].trim_start(); + // Closing quote of a JSON key, then the separator, then the value's quote. + let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start(); + let rest = rest.strip_prefix(['=', ':'])?.trim_start(); + let rest = rest.strip_prefix('"').unwrap_or(rest); + let id: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + if id.is_empty() { + None + } else { + Some(id) + } +} + +/// Walk every `delegate_to_agent` ToolUse block in `turns` and, when it can be +/// matched to a child conversation in `children`, set `meta["codeg.delegation"]` +/// to the DB-derived snapshot. Skips blocks whose meta is already populated so +/// the live-broker write (when present) always wins. Tool-name match is by +/// substring to cover the MCP-prefixed (`mcp__codeg-mcp__delegate_to_agent`) +/// and bare forms the host may have emitted. +/// +/// Matching is by `parent_tool_use_id` first, then by the broker's task id. +/// The fallback is what covers codex: its rollout names the call `call_`, +/// while the broker — which sees the call over the ACP wire, where code mode +/// renames every inner call — recorded `exec-`. The two never meet, so +/// every codex delegation card lost its `child_conversation_id` and with it the +/// "查看会话" affordance. The task id round-trips: the broker mirrors it into +/// `delegation_call_id`, and the ack the model received carries it verbatim. fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationSummary]) { if children.is_empty() { return; @@ -889,6 +919,31 @@ fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationS .iter() .filter_map(|c| c.parent_tool_use_id.as_deref().map(|tu| (tu, c))) .collect(); + let by_task_id: HashMap<&str, &DbConversationSummary> = children + .iter() + .filter_map(|c| c.delegation_call_id.as_deref().map(|id| (id, c))) + .collect(); + + // The task id lives on the call's RESULT, which the parsers emit as a + // separate block (usually a later turn), so collect it up front. + let mut task_id_by_call: HashMap = HashMap::new(); + if !by_task_id.is_empty() { + for turn in turns.iter() { + for block in turn.blocks.iter() { + if let ContentBlock::ToolResult { + tool_use_id: Some(tu), + output_preview: Some(output), + .. + } = block + { + if let Some(task_id) = parse_delegate_task_id(output) { + task_id_by_call.insert(tu.clone(), task_id); + } + } + } + } + } + for turn in turns.iter_mut() { for block in turn.blocks.iter_mut() { if let ContentBlock::ToolUse { @@ -904,7 +959,12 @@ fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationS if !tool_name.contains("delegate_to_agent") { continue; } - if let Some(child) = by_parent_tool_use_id.get(tu.as_str()) { + let child = by_parent_tool_use_id.get(tu.as_str()).or_else(|| { + task_id_by_call + .get(tu.as_str()) + .and_then(|task_id| by_task_id.get(task_id.as_str())) + }); + if let Some(child) = child { *meta = Some(serde_json::json!({ "codeg.delegation": build_historical_delegation_meta(child), })); @@ -2294,6 +2354,83 @@ mod tests { ); } + fn tool_result_turn(tool_use_id: &str, output: &str) -> MessageTurn { + MessageTurn { + id: "t2".into(), + // Tool results are folded into the assistant turn that owns them. + role: TurnRole::Assistant, + blocks: vec![ContentBlock::ToolResult { + tool_use_id: Some(tool_use_id.into()), + output_preview: Some(output.into()), + is_error: false, + agent_stats: None, + images: Vec::new(), + }], + timestamp: chrono::Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + } + } + + /// codex's rollout names the call `call_` while the broker recorded the + /// ACP-side `exec-` — the two never meet, so the card lost its + /// `child_conversation_id` (and the "查看会话" affordance) entirely. The + /// broker's task id, echoed in the ack the model received, is the bridge. + #[test] + fn inject_delegation_meta_falls_back_to_the_task_id() { + let mut turns = vec![ + tool_use_turn(Some("call_73UFK2"), "mcp__codeg_mcp__delegate_to_agent"), + tool_result_turn( + "call_73UFK2", + "Delegation successful. task_id=8ff4c14c-740c-4482-b758-8f2091f97063. \ + Call get_delegation_status with this id in the task_ids array.", + ), + ]; + let mut child = summary_child(2890, "exec-0fb6db94-3042-4cc4-b492-2edd1804c1fa", "completed"); + child.delegation_call_id = Some("8ff4c14c-740c-4482-b758-8f2091f97063".into()); + + inject_delegation_meta(&mut turns, &[child]); + + let inner = first_block_meta(&turns[0]) + .and_then(|m| m.get("codeg.delegation").cloned()) + .expect("meta should be set"); + assert_eq!(inner["child_conversation_id"], 2890); + } + + #[test] + fn inject_delegation_meta_does_not_bind_a_foreign_task_id() { + let mut turns = vec![ + tool_use_turn(Some("call_a"), "delegate_to_agent"), + tool_result_turn("call_a", "Delegation successful. task_id=aaaa."), + ]; + let mut child = summary_child(1, "exec-zzz", "completed"); + child.delegation_call_id = Some("bbbb".into()); + + inject_delegation_meta(&mut turns, &[child]); + + assert!( + first_block_meta(&turns[0]).is_none(), + "a different task's child must not be bound" + ); + } + + #[test] + fn parse_delegate_task_id_reads_both_ack_shapes() { + assert_eq!( + parse_delegate_task_id("Delegation successful. task_id=8ff4c14c-740c. Call …") + .as_deref(), + Some("8ff4c14c-740c") + ); + assert_eq!( + parse_delegate_task_id(r#"{"task_id":"abc-123","status":"running"}"#).as_deref(), + Some("abc-123") + ); + assert_eq!(parse_delegate_task_id("no id here"), None); + assert_eq!(parse_delegate_task_id("task_id="), None); + } + #[test] fn inject_delegation_meta_maps_in_progress_to_running() { let mut turns = vec![tool_use_turn(Some("tu-1"), "delegate_to_agent")]; diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 4d030b22e7..5b994d9a6d 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -9,6 +9,12 @@ use regex::Regex; use walkdir::WalkDir; use crate::models::*; +use crate::parsers::codex_code_mode::{ + extract_chunk_ids, extract_shell_session_ids, is_code_mode_call, parse_code_mode_script, + script_card_input, + split_code_mode_output, with_note, CodeModeCall, CodeModeOutput, CodeModeScript, ScriptStatus, + CODEX_SCRIPT_TOOL_NAME, +}; use crate::parsers::{ folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, }; @@ -361,7 +367,42 @@ fn parse_codex_json_output(payload: &serde_json::Value) -> Option String { +/// A `tools.exec_command()` result the script printed verbatim +/// (`text(JSON.stringify(r))`) — the object form of the very envelope the +/// string path spells out as `Chunk ID: …` / `Wall time: …` / `Output:`. +/// Reduce it to the output it wraps so the card reads like the terminal it is: +/// 9% of code-mode chunks are this shape, and the ones announcing a background +/// session carry an empty `output`, so today those cards show nothing but the +/// envelope. Callers must read whatever they need from the raw text (the +/// session announcement lives in `session_id`) before cleaning. +fn unwrap_exec_chunk_envelope(text: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?; + let obj = value.as_object()?; + // The whole shape, not just `chunk_id`: a command whose own stdout is JSON + // carrying those two keys would otherwise be truncated to its `output` + // field, silently dropping the rest. Every envelope in real transcripts + // carries all four. + if !ENVELOPE_KEYS.iter().all(|key| obj.contains_key(*key)) { + return None; + } + Some(obj.get("output")?.as_str()?.to_string()) +} + +/// Keys every `tools.exec_command()` result object carries, in both the +/// `exit_code` (finished) and `session_id` (still running) variants. +const ENVELOPE_KEYS: [&str; 4] = [ + "chunk_id", + "wall_time_seconds", + "original_token_count", + "output", +]; + +/// The command line and body of a codex exec envelope, or `None` when the text +/// carries neither — i.e. when there is no envelope to strip. Distinct from +/// `clean_codex_exec_output` in exactly one case that matters to a background +/// session: an envelope whose body is empty answers `Some("")` (that poll +/// collected nothing) rather than falling back to the header text. +fn split_codex_exec_output(text: &str) -> Option { let mut cmd_line: Option<&str> = None; let mut in_output = false; let mut output_lines = Vec::new(); @@ -371,7 +412,7 @@ fn clean_codex_exec_output(text: &str) -> String { cmd_line = Some(line); continue; } - if line == "Output:" || line == "Output: " { + if line.trim_end() == "Output:" { in_output = true; continue; } @@ -380,6 +421,10 @@ fn clean_codex_exec_output(text: &str) -> String { } } + if cmd_line.is_none() && !in_output { + return None; + } + let mut result = String::new(); if let Some(cmd) = cmd_line { result.push_str(cmd); @@ -390,12 +435,607 @@ fn clean_codex_exec_output(text: &str) -> String { } result.push_str(&output_lines.join("\n")); } + Some(result) +} - if result.is_empty() { - text.to_string() - } else { - result +fn clean_codex_exec_output(text: &str) -> String { + split_codex_exec_output(text) + .filter(|cleaned| !cleaned.is_empty()) + .unwrap_or_else(|| text.to_string()) +} + +/// Strip whichever exec envelope `text` is wrapped in — the object form or the +/// `Chunk ID:` / `Wall time:` / `Output:` header — or `None` when it is wrapped +/// in neither. The header form is only stripped when the FIRST line announces +/// it: an `Output:` line in the middle of a build log is a build log, not an +/// envelope, and stripping on that alone would swallow everything above it. +fn strip_exec_envelope(text: &str) -> Option { + if let Some(unwrapped) = unwrap_exec_chunk_envelope(text) { + return Some(unwrapped); + } + let first = text.lines().next()?.trim_start().to_ascii_lowercase(); + let announced = ["chunk id:", "wall time", "process ", "script "] + .iter() + .any(|prefix| first.starts_with(prefix)); + if !announced { + return None; + } + split_codex_exec_output(text) +} + +/// Banner codex puts in front of a script's `text()` stream when it re-rendered +/// the whole stream as one blob instead of passing the parts through. +const TRUNCATION_WARNING: &str = "Warning: truncated output"; +const TOTAL_OUTPUT_LINES: &str = "Total output lines: "; + +/// The per-`text()` chunks hiding inside a collapsed output blob, or `None` when +/// recovering them is not provable. +/// +/// Codex re-renders a long script's whole `text()` stream as ONE chunk behind a +/// `Warning: truncated output (original token count: N)` / `Total output lines: +/// M` banner. The boundaries survive in it — one physical line per `text()` — +/// so the per-call split is still there to be had, and without it a +/// `Promise.all` of four commands shows up as a single card whose body is four +/// lines of raw envelope JSON. +/// +/// Recovering it is only sound while all M lines are present: past the cap codex +/// drops whole lines, and it drops them from the MIDDLE, so the survivors' +/// positions stop naming their calls. Measured over the newest 400 rollouts: with +/// every line present, line *i* is call *i* in 36 of 36 checkable cases; with +/// lines dropped, 13 of 57 land on the wrong command. So all three counts have to +/// agree — declared, present, and calls — and every line has to be a complete +/// exec envelope, which is also what tells this blob apart from a single +/// command's stdout that merely got truncated (the far more common banner). +fn uncollapse_truncated_chunks(parsed: &CodeModeOutput, calls: usize) -> Option> { + if calls == 0 { + return None; + } + let [collapsed] = parsed.chunks.as_slice() else { + return None; + }; + let mut lines = collapsed.lines(); + if !lines.next()?.starts_with(TRUNCATION_WARNING) { + return None; + } + let declared: usize = lines + .next()? + .strip_prefix(TOTAL_OUTPUT_LINES)? + .trim() + .parse() + .ok()?; + if !lines.next()?.is_empty() { + return None; + } + let body: Vec = lines.map(str::to_string).collect(); + if body.len() != declared || declared != calls { + return None; + } + body.iter() + .all(|line| unwrap_exec_chunk_envelope(line).is_some()) + .then_some(body) +} + +/// `text` trimmed with every run of digits collapsed to `#`, so `---RESULT 1---` +/// and `---RESULT 2---` compare equal while `--- dto diff ---` and +/// `--- handler diff ---` do not. +fn digit_blind(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut digits = false; + for ch in text.trim().chars() { + if ch.is_ascii_digit() { + if !digits { + out.push('#'); + digits = true; + } + } else { + digits = false; + out.push(ch); + } + } + out +} + +/// How many `text()` chunks each call contributed — 1 for the plain +/// `results.forEach(r => text(r.output))` shape — or `None` when the chunks do +/// not provably divide into one group per call. +/// +/// Scripts routinely print more than one chunk per call: a `---RESULT n---` +/// header before the output, an `exit_code=0` after it. 268 of the 1297 +/// multi-call scripts that fail the 1:1 count in the newest 400 rollouts are +/// exactly this, and they render as one undecomposable script card today. +/// +/// The count alone proves nothing — a script that prints every output first and +/// every exit code after hands over the same chunks in the wrong order, and +/// slicing those into pairs would pin each command's output to the previous +/// command's card. The proof is `text_run` (see `CodeModeScript`): exactly +/// `stride` `text()` calls with no loop between the first and the last, so that +/// run emitted its chunks together, `calls` times over. A repeating slot is kept +/// as corroboration — it is only evidence, since a command's own stdout can be +/// `exit_code=0` — and costs 13 of 256 candidates, which stay script cards. +/// +/// Measured with commands whose output names its own call (`nl -ba F | sed -n +/// 'A,Bp'` must start at line A): 152 of 153 groups land on the right command, +/// and the one exception is the check's own blind spot — that `nl` failed and +/// printed a usage error, so there was no line number to match. +fn call_chunk_stride(chunks: &[String], calls: usize, text_run: Option) -> Option { + if calls == 0 || chunks.is_empty() || chunks.len() % calls != 0 { + return None; + } + let stride = chunks.len() / calls; + if stride == 1 { + return Some(1); + } + if text_run != Some(stride) { + return None; + } + (0..stride) + .any(|slot| { + let marker = digit_blind(&chunks[slot]); + !marker.is_empty() + && (1..calls).all(|index| digit_blind(&chunks[index * stride + slot]) == marker) + }) + .then_some(stride) +} + +/// Turn one code-mode script + its output envelope into the tool blocks the +/// pre-code-mode history path produced. +/// +/// Returns `(replacement tool_use blocks, tool_result blocks)`. The first is +/// `None` when the script could not be decomposed — the placeholder script card +/// stays and simply receives the whole output. +/// +/// Splitting per call needs the chunks to divide into one group per call, in +/// order — see `call_chunk_stride` for what counts as proof. Anything less falls +/// back to the script card rather than misattributing output, including the +/// collapsed blob codex renders in place of the chunks when the stream is long, +/// unless `uncollapse_truncated_chunks` can prove the chunks back out of it. +fn unwrap_code_mode_script( + call_id: &str, + script: &CodeModeScript, + parsed: &CodeModeOutput, + payload: &serde_json::Value, + sessions: &mut HashMap, + poll_origins: &mut HashMap, +) -> (Option>, Vec) { + let calls = script.calls.as_deref().unwrap_or_default(); + + let result_block = |tool_use_id: Option, text: Option, note: Option<&str>| { + let output_preview = with_note(text, note); + let is_error = parsed.is_error() + || infer_tool_call_output_is_error(payload, None, output_preview.as_deref()); + ContentBlock::ToolResult { + tool_use_id, + output_preview, + is_error, + agent_stats: None, + images: Vec::new(), + } + }; + + let non_empty = |text: String| { + if text.is_empty() { + None + } else { + Some(text) + } + }; + + // NB: the envelope note (`Script running with cell ID N`) is deliberately + // NOT registered as a shell session here. That cell is the SCRIPT — it has + // not finished — so the `wait` collecting it carries this very script's + // return value and is folded back onto these cards by the caller + // (`DeferredScript`), never rendered as a session of its own. + + // A collapsed blob stands in for the chunks it was rendered from, when the + // counts prove which is which. Nothing changes when it does not: the + // fallback is `parsed.chunks` itself, banner and all. + let uncollapsed = uncollapse_truncated_chunks(parsed, calls.len()); + let chunks: &[String] = uncollapsed.as_deref().unwrap_or(&parsed.chunks); + + if calls.len() == 1 { + let call = &calls[0]; + let joined = chunks.join("\n"); + let uses = vec![ContentBlock::ToolUse { + tool_use_id: Some(call_id.to_string()), + tool_name: call.tool_name.clone(), + input_preview: Some(shell_session_input_preview( + call, + call_id, + sessions, + poll_origins, + )), + meta: None, + }]; + register_shell_sessions(call, call_id, &joined, sessions); + let results = vec![result_block( + Some(call_id.to_string()), + non_empty(exec_chunk_display(call, joined)), + parsed.note.as_deref(), + )]; + return (Some(uses), results); + } + + if let Some(stride) = (calls.len() > 1) + .then(|| call_chunk_stride(chunks, calls.len(), script.text_run)) + .flatten() + { + let last = calls.len() - 1; + let mut uses = Vec::with_capacity(calls.len()); + let mut results = Vec::with_capacity(calls.len()); + for (index, call) in calls.iter().enumerate() { + let id = format!("{call_id}#{index}"); + let note = if index == last { + parsed.note.as_deref() + } else { + None + }; + let group = &chunks[index * stride..(index + 1) * stride]; + uses.push(ContentBlock::ToolUse { + tool_use_id: Some(id.clone()), + tool_name: call.tool_name.clone(), + input_preview: Some(shell_session_input_preview( + call, + &id, + sessions, + poll_origins, + )), + meta: None, + }); + // Announcements are read from the chunks as written: unwrapping an + // envelope drops the `session_id` that names the session. + register_shell_sessions(call, &id, &group.join("\n"), sessions); + let shown = group + .iter() + .map(|chunk| exec_chunk_display(call, chunk.clone())) + .collect::>() + .join("\n"); + results.push(result_block(Some(id), non_empty(shown), note)); + } + return (Some(uses), results); + } + + // Undecomposable script: the card stays a script card, and no session it + // announced is attributed. There is no way to tell which of its calls + // started one — that is exactly why it did not decompose. + let joined = chunks.join("\n"); + ( + None, + vec![result_block( + Some(call_id.to_string()), + non_empty(joined), + parsed.note.as_deref(), + )], + ) +} + +/// A code-mode script whose output was `Script running with cell ID N`: the +/// script itself has not finished. Remembers where its cards were written so +/// the `wait` that collects cell N — its real return value, arriving any number +/// of turns later — can be folded back onto them instead of rendering as a +/// separate card. Indices are stable because the parse loop only appends. +#[derive(Clone)] +struct DeferredScript { + call_id: String, + /// Index of the message holding the script's ToolUse block(s). + use_index: usize, + /// Index of the message holding its ToolResult block(s). + result_index: usize, + script: CodeModeScript, + /// Everything the script has printed so far. A `wait` answers with what has + /// been printed SINCE — measured on real transcripts, the collected answer + /// shares nothing with the one that parked the script — so the chunks have + /// to be accumulated and re-decomposed as one sequence. Replacing the cards + /// from the `wait` alone would drop whatever the script had already printed, + /// and would decompose against a chunk count that is missing its front. + chunks: Vec, +} + +/// codex's unified-exec session tools. Neither carries a command of its own: +/// both address a background shell started by an earlier `exec_command`, by the +/// id that command's output announced (`wait` calls it `cell_id`, +/// `write_stdin` calls it `session_id`). +const SHELL_SESSION_TOOLS: [&str; 2] = ["wait", "write_stdin"]; + +/// Key added to a `wait` / `write_stdin` `input_preview` carrying the command +/// that started the session it addresses. +/// +/// Deliberately NOT `command`: `inferFromInput` (`tool-call-normalization.ts`) +/// classifies any live input carrying `command`/`cmd`/… as a terminal call, so +/// that spelling would hijack the live classification of these tools. +const SESSION_COMMAND_KEY: &str = "session_command"; + +/// A background shell an `exec_command` left running: unified-exec answers that +/// command with whatever it printed so far plus `Process running with session +/// ID N`, and the rest of the output has to be collected by later `wait` / +/// `write_stdin` calls addressing N. +#[derive(Clone)] +struct ShellSession { + /// The command that started it. Titles the calls that address it. + command: String, + /// `tool_use_id` of the card holding that command's output, while more of + /// it may still be appended there. `None` once the session got a card of + /// its own (keystrokes, termination): output arriving after that card + /// belongs below it, not folded back above it. + origin: Option, +} + +fn is_shell_session_tool(tool_name: &str) -> bool { + SHELL_SESSION_TOOLS.contains(&tool_name) +} + +/// The text a recovered call's card shows. Only the exec family answers with +/// the chunk envelope, and only there is unwrapping it provably right — another +/// tool returning an object that happens to carry `chunk_id` would be showing +/// its own result, not a terminal's. +fn exec_chunk_display(call: &CodeModeCall, chunk: String) -> String { + if call.tool_name != "exec_command" && !is_shell_session_tool(&call.tool_name) { + return chunk; + } + strip_exec_envelope(&chunk).unwrap_or(chunk) +} + +/// Whether a `wait` / `write_stdin` only collects output — no keystrokes to +/// show, no termination to report. Such a call *is* the earlier command still +/// running, so it gets no card and its output is appended to that command's. +fn is_pure_poll(args: &serde_json::Map) -> bool { + let sends_input = args + .get("chars") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let terminates = args + .get("terminate") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + !sends_input && !terminates +} + +/// The session id a `wait` / `write_stdin` argument object addresses, as a +/// string — `wait` sends `"cell_id":"7106"`, `write_stdin` sends +/// `"session_id":33067`. +fn shell_session_id(args: &serde_json::Map) -> Option { + let value = args.get("cell_id").or_else(|| args.get("session_id"))?; + match value { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Annotate a `wait` / `write_stdin` argument JSON with the command that +/// started the session it addresses, so the card can be titled by that command +/// instead of a bare `wait`. Returns `None` when the arguments aren't an +/// object, carry no session id, or the id was never announced — the caller then +/// keeps the arguments untouched rather than inventing a command. +fn annotate_shell_session_args( + args: &serde_json::Value, + sessions: &HashMap, +) -> Option { + let obj = args.as_object()?; + let session = sessions.get(&shell_session_id(obj)?)?; + let mut annotated = obj.clone(); + annotated.insert( + SESSION_COMMAND_KEY.to_string(), + serde_json::Value::String(session.command.clone()), + ); + Some(serde_json::Value::Object(annotated).to_string()) +} + +/// Same annotation for a session tool recovered from a code-mode script, whose +/// `input_preview` is already the argument object's JSON. +fn shell_session_input_preview( + call: &CodeModeCall, + tool_use_id: &str, + sessions: &mut HashMap, + poll_origins: &mut HashMap, +) -> String { + if !is_shell_session_tool(&call.tool_name) { + return call.input_preview.clone(); + } + let args = serde_json::from_str::(&call.input_preview).ok(); + note_shell_session_call(Some(tool_use_id), args.as_ref(), sessions, poll_origins); + args.and_then(|args| annotate_shell_session_args(&args, sessions)) + .unwrap_or_else(|| call.input_preview.clone()) +} + +/// Record `session id → command` for every background session this call's +/// output announced. Only `exec_command` starts sessions, and only its own +/// output can name them, so nothing else is registered — a wrong mapping would +/// put someone else's command in a `wait` card's title. +fn register_shell_sessions( + call: &CodeModeCall, + origin: &str, + output: &str, + sessions: &mut HashMap, +) { + // `args_inline` is the proof that this command is what actually ran: one + // call site executes any number of times, and arguments reached through a + // variable can be reassigned between iterations, leaving the recovered + // command a guess. Titling — and now folding — a session under a guess is + // exactly the failure this parser must not have. + if call.tool_name != "exec_command" || !call.args_inline { + return; + } + // `input_preview` of a recovered `exec_command` is the bare command string. + register_announced_sessions(&call.input_preview, origin, output, sessions); +} + +/// `origin` is the `tool_use_id` of the card this output is being written to — +/// where the session's remaining output gets appended once a later poll +/// collects it. +fn register_announced_sessions( + command: &str, + origin: &str, + output: &str, + sessions: &mut HashMap, +) { + if command.trim().is_empty() { + return; + } + let announced = extract_shell_session_ids(output); + if announced.is_empty() { + return; + } + // The chunk ids of the same output are aliases for the cells it announced — + // codex addresses them either way. Registered only alongside a real + // announcement, so a chunk id from a command that already exited stays + // meaningless. + for id in announced.into_iter().chain(extract_chunk_ids(output)) { + sessions.insert( + id, + ShellSession { + command: command.to_string(), + origin: Some(origin.to_string()), + }, + ); + } +} + +/// Classify a `wait` / `write_stdin` against the session it addresses: either +/// it only collects more of that session's output — in which case it is marked +/// for folding into the card of the command producing it — or it is an action +/// (keystrokes, termination) that earns a card, and everything the session +/// prints from then on belongs below that card rather than folded back above +/// it. Sessions nobody announced fall through both: nothing to fold into. +fn note_shell_session_call( + tool_use_id: Option<&str>, + args: Option<&serde_json::Value>, + sessions: &mut HashMap, + poll_origins: &mut HashMap, +) { + let Some(args) = args.and_then(|a| a.as_object()) else { + return; + }; + let Some(session_id) = shell_session_id(args) else { + return; + }; + let Some(origin) = sessions.get(&session_id).and_then(|s| s.origin.clone()) else { + return; + }; + if is_pure_poll(args) { + if let Some(call_id) = tool_use_id { + poll_origins.insert(call_id.to_string(), origin); + } + return; + } + // End the folding for every id naming this cell, not just the one this call + // addressed: the numeric session id and the hex chunk id are aliases, and a + // poll arriving through the other spelling would otherwise still be folded + // in above the card that caused it. + for session in sessions.values_mut() { + if session.origin.as_deref() == Some(origin.as_str()) { + session.origin = None; + } + } +} + +/// Drop every card that only collects more of an earlier command's output, +/// moving what it collected onto that command's card. Runs on the finished +/// message list so a poll codex called directly and one a code-mode script +/// wrote are folded the same way — the script path builds its cards inside +/// `unwrap_code_mode_script`, long after the call itself was read. +fn fold_shell_session_polls( + messages: &mut Vec, + poll_origins: &HashMap, +) { + if poll_origins.is_empty() { + return; + } + + // Where every tool block lives, and — in transcript order — the polls to + // fold. Collected up front: a poll's origin always sits earlier, so folding + // as we walk would need the map anyway, and both blocks of a poll have to be + // located before either can be dropped. + let mut result_at: HashMap = HashMap::new(); + let mut use_at: HashMap = HashMap::new(); + let mut folds: Vec<(String, String, usize, usize)> = Vec::new(); + for (mi, message) in messages.iter().enumerate() { + for (bi, block) in message.content.iter().enumerate() { + match block { + ContentBlock::ToolUse { + tool_use_id: Some(id), + .. + } => { + use_at.insert(id.clone(), (mi, bi)); + } + ContentBlock::ToolResult { + tool_use_id: Some(id), + .. + } => match poll_origins.get(id) { + Some(origin) => folds.push((id.clone(), origin.clone(), mi, bi)), + None => { + result_at.insert(id.clone(), (mi, bi)); + } + }, + _ => {} + } + } + } + + let mut drop_at: Vec<(usize, usize)> = Vec::new(); + for (poll_id, origin, mi, bi) in folds { + // No origin block left — a code-mode script re-decomposed under it, say. + // Leave the poll's own card alone rather than dropping what it collected. + let Some(&(omi, obi)) = result_at.get(&origin) else { + continue; + }; + let ContentBlock::ToolResult { + output_preview, + is_error, + .. + } = &messages[mi].content[bi] + else { + continue; + }; + let collected = output_preview.clone().unwrap_or_default(); + let collected = collected.trim_end().to_string(); + let collected_error = *is_error; + + let ContentBlock::ToolResult { + output_preview, + is_error, + .. + } = &mut messages[omi].content[obi] + else { + continue; + }; + if !collected.is_empty() { + match output_preview { + Some(existing) if !existing.trim().is_empty() => { + while existing.ends_with('\n') { + existing.pop(); + } + existing.push('\n'); + existing.push_str(&collected); + } + _ => *output_preview = Some(collected), + } + } + *is_error = *is_error || collected_error; + + drop_at.push((mi, bi)); + if let Some(&at) = use_at.get(&poll_id) { + drop_at.push(at); + } + } + + if drop_at.is_empty() { + return; } + let emptied: HashSet = drop_at.iter().map(|(mi, _)| *mi).collect(); + drop_at.sort_unstable(); + // Two polls sharing a `tool_use_id` would name the same use block twice, and + // removing a block index twice panics. Ids are unique in practice; a broken + // transcript must not take the whole conversation down with it. + drop_at.dedup(); + for (mi, bi) in drop_at.into_iter().rev() { + messages[mi].content.remove(bi); + } + let mut index = 0; + messages.retain(|m| { + let keep = !m.content.is_empty() || !emptied.contains(&index); + index += 1; + keep + }); } fn value_to_preview(value: Option<&serde_json::Value>) -> Option { @@ -702,7 +1342,9 @@ fn parse_codex_subagent_stats( let reader = BufReader::new(file); let mut tool_calls = Vec::new(); - let mut pending_calls: HashMap = HashMap::new(); + // A code-mode `exec` call expands to one entry per inner `tools.*` call, so + // the sub-agent stats count real tools instead of a pile of `exec`. + let mut pending_calls: HashMap> = HashMap::new(); let mut first_ts: Option> = None; let mut last_ts: Option> = None; @@ -746,28 +1388,60 @@ fn parse_codex_subagent_stats( .unwrap_or("unknown") .to_string(); - let input_preview = if tool_name == "exec_command" { - parse_codex_json_arg(payload) - .and_then(|a| a.get("cmd").and_then(|v| v.as_str()).map(|s| s.to_string())) - .or_else(|| { - value_to_preview( - payload.get("arguments").or_else(|| payload.get("input")), - ) - }) + let entries = if is_code_mode_call(&tool_name) { + let source = payload + .get("input") + .or_else(|| payload.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let script = parse_code_mode_script(source); + match script.calls { + Some(calls) if !calls.is_empty() => calls + .into_iter() + .map(|call| AgentToolCall { + tool_name: call.tool_name, + input_preview: Some(truncate_str(&call.input_preview, 500)), + output_preview: None, + is_error: false, + }) + .collect(), + _ => vec![AgentToolCall { + tool_name: CODEX_SCRIPT_TOOL_NAME.to_string(), + input_preview: script + .summary + .or_else(|| Some(source.to_string())) + .map(|s| truncate_str(&s, 500)), + output_preview: None, + is_error: false, + }], + } } else { - value_to_preview(payload.get("arguments").or_else(|| payload.get("input"))) + let input_preview = if tool_name == "exec_command" { + parse_codex_json_arg(payload) + .and_then(|a| { + a.get("cmd").and_then(|v| v.as_str()).map(|s| s.to_string()) + }) + .or_else(|| { + value_to_preview( + payload.get("arguments").or_else(|| payload.get("input")), + ) + }) + } else { + value_to_preview(payload.get("arguments").or_else(|| payload.get("input"))) + }; + + vec![AgentToolCall { + tool_name, + input_preview: input_preview.map(|s| truncate_str(&s, 500)), + output_preview: None, + is_error: false, + }] }; - let tc = AgentToolCall { - tool_name, - input_preview: input_preview.map(|s| truncate_str(&s, 500)), - output_preview: None, - is_error: false, - }; if let Some(id) = call_id { - pending_calls.insert(id, tc); + pending_calls.insert(id, entries); } else { - tool_calls.push(tc); + tool_calls.extend(entries); } } "function_call_output" | "custom_tool_call_output" => { @@ -778,21 +1452,40 @@ fn parse_codex_subagent_stats( .and_then(|id| id.as_str()); if let Some(id) = call_id { - if let Some(mut tc) = pending_calls.remove(id) { + if let Some(entries) = pending_calls.remove(id) { let output_value = payload.get("output"); - let raw_output = value_to_preview(output_value); - if tc.tool_name == "exec_command" { - tc.output_preview = - raw_output.map(|s| truncate_str(&clean_codex_exec_output(&s), 500)); + let envelope = split_code_mode_output(output_value); + let per_call = entries.len() > 1 && envelope.chunks.len() == entries.len(); + let joined = if envelope.status != ScriptStatus::Unknown + || output_value.is_some_and(|v| v.is_array()) + { + Some(envelope.joined()) } else { - tc.output_preview = raw_output.map(|s| truncate_str(&s, 500)); + value_to_preview(output_value) + }; + + for (index, mut tc) in entries.into_iter().enumerate() { + let raw_output = if per_call { + Some(envelope.chunks[index].clone()) + } else if index == 0 { + joined.clone() + } else { + None + }; + if tc.tool_name == "exec_command" { + tc.output_preview = raw_output + .map(|s| truncate_str(&clean_codex_exec_output(&s), 500)); + } else { + tc.output_preview = raw_output.map(|s| truncate_str(&s, 500)); + } + tc.is_error = envelope.is_error() + || infer_tool_call_output_is_error( + payload, + output_value, + tc.output_preview.as_deref(), + ); + tool_calls.push(tc); } - tc.is_error = infer_tool_call_output_is_error( - payload, - output_value, - tc.output_preview.as_deref(), - ); - tool_calls.push(tc); } } } @@ -800,7 +1493,7 @@ fn parse_codex_subagent_stats( } } - tool_calls.extend(pending_calls.into_values()); + tool_calls.extend(pending_calls.into_values().flatten()); let total_duration_ms = match (first_ts, last_ts) { (Some(f), Some(l)) => { @@ -919,6 +1612,31 @@ impl CodexParser { let mut close_agent_targets: HashMap = HashMap::new(); let mut active_agent_count: u32 = 0; let mut call_id_tool_names: HashMap = HashMap::new(); + // Code-mode scripts (`custom_tool_call` named `exec`) awaiting their + // output. A placeholder script card is pushed when the call is read, so + // an interrupted turn still shows it in place; the output arm rewrites + // that message's blocks once it knows how many `text()` chunks came + // back. See `parsers/codex_code_mode.rs`. + let mut pending_exec_scripts: HashMap = HashMap::new(); + // `exec_command` call_id → the command it ran, and the background shell + // sessions that command's output announced (`session id → command`). + // A later `wait` / `write_stdin` carries only the session id, so this is + // what lets its card be titled by the command it is waiting on instead + // of a bare `wait`. See `extract_shell_session_ids`. + let mut call_id_commands: HashMap = HashMap::new(); + let mut shell_sessions: HashMap = HashMap::new(); + // Calls that only collect more of an earlier command's output: poll + // `tool_use_id` → `tool_use_id` of the card holding that command. The + // fold happens once parsing is done (`fold_shell_session_polls`), which + // is what lets a poll written inside a code-mode script be folded on the + // same footing as one codex called directly. + let mut poll_origins: HashMap = HashMap::new(); + // Scripts that answered `Script running with cell ID N` — they have NOT + // finished, and the `wait` that later collects cell N carries their own + // return value. Keyed by cell id so a parallel batch of waits each folds + // into the right script no matter what order the records interleave in. + let mut deferred_scripts: HashMap = HashMap::new(); + let mut deferred_waits: HashMap = HashMap::new(); // Codex 0.129+ writes a generated image both as `event_msg.image_generation_end` // and as `response_item.image_generation_call`, sharing the same call_id/id. // Emit at most one ContentBlock::Image per id to avoid duplicate display. @@ -1411,7 +2129,30 @@ impl CodexParser { .and_then(|n| n.as_str()) .unwrap_or("unknown"); + // A `wait` collecting a code-mode script that + // has not finished. Matched by cell id, never by + // adjacency — parallel calls interleave freely. + let deferred_cell = if raw_tool_name == "wait" { + parse_codex_json_arg(payload) + .as_ref() + .and_then(|a| a.as_object()) + .and_then(shell_session_id) + .filter(|cell| deferred_scripts.contains_key(cell)) + } else { + None + }; + match raw_tool_name { + // No card of its own: the output it is about + // to collect IS the parked script's return + // value, and the output arm writes it back + // onto that script's cards. + _ if deferred_cell.is_some() => { + if let (Some(id), Some(cell)) = (tool_use_id, deferred_cell) + { + deferred_waits.insert(id, cell); + } + } "spawn_agent" => { let args = parse_codex_json_arg(payload); let agent_type = args @@ -1473,32 +2214,93 @@ impl CodexParser { } } } + // Code mode: the whole turn's tool calls are + // wrapped in one JS script. Park a script + // card here and let the output arm replace + // it with the real per-call cards — the + // split depends on how many `text()` chunks + // came back, which is only known then. An + // interrupted turn never gets an output, so + // the placeholder must be pushed now to keep + // its position. + _ if is_code_mode_call(raw_tool_name) => { + let source = payload + .get("input") + .or_else(|| payload.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let script = parse_code_mode_script(source); + if let Some(ref id) = tool_use_id { + pending_exec_scripts.insert( + id.clone(), + (messages.len(), script.clone()), + ); + } + messages.push(UnifiedMessage { + id: format!("tool-{}", messages.len()), + role: MessageRole::Assistant, + content: vec![ContentBlock::ToolUse { + tool_use_id, + tool_name: CODEX_SCRIPT_TOOL_NAME.to_string(), + input_preview: Some(script_card_input( + source, + script.summary.as_deref(), + script.call_sites, + )), + meta: None, + }], + timestamp, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(timestamp), + }); + } _ => { if let Some(ref id) = tool_use_id { call_id_tool_names .insert(id.clone(), raw_tool_name.to_string()); } - let input_preview = if raw_tool_name == "exec_command" { - parse_codex_json_arg(payload) - .and_then(|a| { - a.get("cmd") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .or_else(|| { - value_to_preview( - payload - .get("arguments") - .or_else(|| payload.get("input")), - ) - }) - } else { + let raw_args = || { value_to_preview( payload .get("arguments") .or_else(|| payload.get("input")), ) }; + let input_preview = if raw_tool_name == "exec_command" { + let cmd = parse_codex_json_arg(payload).and_then(|a| { + a.get("cmd") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + // Remembered so this command can be + // attached to whatever background + // session its output announces. + if let (Some(id), Some(cmd)) = + (tool_use_id.as_ref(), cmd.as_ref()) + { + call_id_commands.insert(id.clone(), cmd.clone()); + } + cmd.or_else(raw_args) + } else if is_shell_session_tool(raw_tool_name) { + // `wait` / `write_stdin`: carry the + // command that started the session + // they address, when it is known. + let args = parse_codex_json_arg(payload); + note_shell_session_call( + tool_use_id.as_deref(), + args.as_ref(), + &mut shell_sessions, + &mut poll_origins, + ); + args.and_then(|args| { + annotate_shell_session_args(&args, &shell_sessions) + }) + .or_else(raw_args) + } else { + raw_args() + }; messages.push(UnifiedMessage { id: format!("tool-{}", messages.len()), role: MessageRole::Assistant, @@ -1534,8 +2336,96 @@ impl CodexParser { let is_close = tool_use_id .as_ref() .is_some_and(|id| close_agent_call_ids.contains(id)); - - if is_spawn { + let pending_script = tool_use_id + .as_ref() + .and_then(|id| pending_exec_scripts.remove(id)); + let deferred = tool_use_id + .as_ref() + .and_then(|id| deferred_waits.remove(id)) + .and_then(|cell| deferred_scripts.remove(&cell)); + + if let Some(deferred) = deferred { + // The parked script's real return value, at + // last. Re-decompose it against that script + // and rewrite the cards it already owns — + // this `wait` contributes no card of its own. + let collected = split_code_mode_output(payload.get("output")); + // The whole run, not just this instalment: + // see `DeferredScript::chunks`. + let parsed = CodeModeOutput { + status: collected.status, + chunks: deferred + .chunks + .iter() + .cloned() + .chain(collected.chunks) + .collect(), + note: collected.note, + }; + let (uses, results) = unwrap_code_mode_script( + &deferred.call_id, + &deferred.script, + &parsed, + payload, + &mut shell_sessions, + &mut poll_origins, + ); + if let Some(uses) = uses { + messages[deferred.use_index].content = uses; + } + messages[deferred.result_index].content = results; + // Still not done: whatever cell it reports + // now is what the next `wait` collects. + if parsed.status == ScriptStatus::Running { + let deferred = DeferredScript { + chunks: parsed.chunks, + ..deferred + }; + for cell in extract_shell_session_ids( + parsed.note.as_deref().unwrap_or_default(), + ) { + deferred_scripts.insert(cell, deferred.clone()); + } + } + } else if let Some((message_index, script)) = pending_script { + let call_id = tool_use_id.unwrap_or_default(); + let parsed = split_code_mode_output(payload.get("output")); + let (uses, results) = unwrap_code_mode_script( + &call_id, + &script, + &parsed, + payload, + &mut shell_sessions, + &mut poll_origins, + ); + if let Some(uses) = uses { + messages[message_index].content = uses; + } + if parsed.status == ScriptStatus::Running { + let deferred = DeferredScript { + call_id: call_id.clone(), + use_index: message_index, + result_index: messages.len(), + script: script.clone(), + chunks: parsed.chunks.clone(), + }; + for cell in extract_shell_session_ids( + parsed.note.as_deref().unwrap_or_default(), + ) { + deferred_scripts.insert(cell, deferred.clone()); + } + } + messages.push(UnifiedMessage { + id: format!("tool-result-{}", messages.len()), + role: MessageRole::Tool, + content: results, + timestamp, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(timestamp), + }); + } else if is_spawn { if let Some(output_obj) = parse_codex_json_output(payload) { if let (Some(agent_id), Some(call_id)) = ( output_obj.get("agent_id").and_then(|v| v.as_str()), @@ -1656,17 +2546,76 @@ impl CodexParser { .is_some_and(|n| n == "exec_command") }); let output_value = payload.get("output"); - let raw_output = value_to_preview(output_value); - let output = if is_exec { + // Unified-exec tools (`wait`, …) share the + // code-mode output shape: an ARRAY of + // `{type:"input_text", text}` parts behind a + // `Script …/Wall time …/Output:` header. + // `value_to_preview` would dump that array as + // raw JSON into the result panel. + let envelope = split_code_mode_output(output_value); + // An `exec_command` that left a background + // shell running announces its id here; bind + // it to the command so the `wait` / + // `write_stdin` calls that address it later + // can be titled by that command. Read before + // `clean_codex_exec_output`, which drops + // everything above the `Output:` line — the + // announcement included. + if let Some((origin, command)) = + tool_use_id.as_ref().and_then(|id| { + call_id_commands.get(id).map(|cmd| (id.clone(), cmd)) + }) + { + let mut announced = envelope.joined(); + if let Some(note) = envelope.note.as_deref() { + announced.push('\n'); + announced.push_str(note); + } + register_announced_sessions( + command, + &origin, + &announced, + &mut shell_sessions, + ); + } + let (raw_output, envelope_error) = + if envelope.status != ScriptStatus::Unknown + || output_value.is_some_and(|v| v.is_array()) + { + ( + with_note( + Some(envelope.joined()), + envelope.note.as_deref(), + ) + .filter(|s| !s.is_empty()), + envelope.is_error(), + ) + } else { + (value_to_preview(output_value), false) + }; + // A poll about to be folded into the card of + // the command it is collecting for: its + // envelope has to go, and an envelope that + // wrapped nothing has to end up empty rather + // than falling back to its own header. + let is_folded_poll = tool_use_id + .as_ref() + .is_some_and(|id| poll_origins.contains_key(id)); + let output = if is_folded_poll { + raw_output + .map(|s| strip_exec_envelope(&s).unwrap_or(s)) + .filter(|s| !s.is_empty()) + } else if is_exec { raw_output.map(|s| clean_codex_exec_output(&s)) } else { raw_output }; - let is_error = infer_tool_call_output_is_error( - payload, - output_value, - output.as_deref(), - ); + let is_error = envelope_error + || infer_tool_call_output_is_error( + payload, + output_value, + output.as_deref(), + ); messages.push(UnifiedMessage { id: format!("tool-result-{}", messages.len()), role: MessageRole::Tool, @@ -1894,6 +2843,7 @@ impl CodexParser { let folder_path = cwd.clone(); let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); + fold_shell_session_polls(&mut messages, &poll_origins); let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); @@ -2427,6 +3377,7 @@ mod tests { use super::should_skip_duplicate_user_message; use super::strip_blocked_resource_mentions; use super::CodexParser; + use super::CODEX_SCRIPT_TOOL_NAME; use crate::models::{ ContentBlock, MessageRole, MessageTurn, SessionStats, TurnRole, TurnUsage, UnifiedMessage, }; @@ -4447,4 +5398,1317 @@ mod tests { let _ = fs::remove_file(path); } + + // ── codex code mode ────────────────────────────────────────────────── + // + // Newer codex wraps EVERY tool call in a JS script persisted as one + // `custom_tool_call` named `exec`. Without unwrapping, the whole history + // renders as `const r = await tools.…` shell cards (see + // `parsers/codex_code_mode.rs`). + + fn tool_uses( + detail: &crate::models::ConversationDetail, + ) -> Vec<(String, String, Option)> { + detail + .turns + .iter() + .flat_map(|t| t.blocks.iter()) + .filter_map(|b| match b { + ContentBlock::ToolUse { + tool_use_id, + tool_name, + input_preview, + .. + } => Some(( + tool_use_id.clone().unwrap_or_default(), + tool_name.clone(), + input_preview.clone(), + )), + _ => None, + }) + .collect() + } + + fn tool_results( + detail: &crate::models::ConversationDetail, + ) -> Vec<(String, Option, bool)> { + detail + .turns + .iter() + .flat_map(|t| t.blocks.iter()) + .filter_map(|b| match b { + ContentBlock::ToolResult { + tool_use_id, + output_preview, + is_error, + .. + } => Some(( + tool_use_id.clone().unwrap_or_default(), + output_preview.clone(), + *is_error, + )), + _ => None, + }) + .collect() + } + + fn code_mode_rollout(script: &str, output: serde_json::Value) -> Vec { + vec![ + rollout_line( + "2026-07-20T08:40:00Z", + "event_msg", + serde_json::json!({"type": "user_message", "message": "go"}), + ), + rollout_line( + "2026-07-20T08:40:01Z", + "response_item", + serde_json::json!({ + "type": "custom_tool_call", + "name": "exec", + "call_id": "call_1", + "input": script, + }), + ), + rollout_line( + "2026-07-20T08:40:02Z", + "response_item", + serde_json::json!({ + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": output, + }), + ), + ] + } + + #[test] + fn code_mode_single_call_renders_as_the_inner_tool() { + let lines = code_mode_rollout( + "const r = await tools.exec_command({\n cmd: \"git status --short\",\n workdir: \"/repo\"\n});\ntext(r.output);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.5 seconds\nOutput:\n"}, + {"type": "input_text", "text": " M src/main.rs"}, + ]), + ); + let path = write_temp_rollout("code-mode-single", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-single") + .expect("parse ok"); + + assert_eq!( + tool_uses(&detail), + vec![( + "call_1".to_string(), + "exec_command".to_string(), + Some("git status --short".to_string()) + )] + ); + assert_eq!( + tool_results(&detail), + vec![( + "call_1".to_string(), + Some(" M src/main.rs".to_string()), + false + )] + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn code_mode_parallel_calls_split_output_per_card() { + let lines = code_mode_rollout( + "const rs = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nrs.forEach(r => text(r.output));\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "out-one"}, + {"type": "input_text", "text": "out-two"}, + {"type": "input_text", "text": "out-three"}, + ]), + ); + let path = write_temp_rollout("code-mode-split", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-split") + .expect("parse ok"); + + assert_eq!( + tool_uses(&detail), + vec![ + ( + "call_1#0".to_string(), + "exec_command".to_string(), + Some("one".to_string()) + ), + ( + "call_1#1".to_string(), + "exec_command".to_string(), + Some("two".to_string()) + ), + ( + "call_1#2".to_string(), + "exec_command".to_string(), + Some("three".to_string()) + ), + ] + ); + assert_eq!( + tool_results(&detail), + vec![ + ("call_1#0".to_string(), Some("out-one".to_string()), false), + ("call_1#1".to_string(), Some("out-two".to_string()), false), + ("call_1#2".to_string(), Some("out-three".to_string()), false), + ] + ); + + let _ = fs::remove_file(path); + } + + const THREE_CALLS: &str = "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nfor (const x of r) { text(x.output); text(`exit_code=${x.exit_code}`); }\n"; + + /// `text(x.output); text(\`exit_code=…\`)` — two chunks per call, so the 1:1 + /// count fails and the whole thing used to stay one script card. + #[test] + fn a_trailing_chunk_per_call_still_splits_per_call() { + let lines = code_mode_rollout( + THREE_CALLS, + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "out-one"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "out-two"}, + {"type": "input_text", "text": "exit_code=1"}, + {"type": "input_text", "text": "out-three"}, + {"type": "input_text", "text": "exit_code=0"}, + ]), + ); + let path = write_temp_rollout("code-mode-stride-tail", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-stride-tail") + .expect("parse ok"); + + assert_eq!( + tool_uses(&detail), + vec![ + ( + "call_1#0".to_string(), + "exec_command".to_string(), + Some("one".to_string()) + ), + ( + "call_1#1".to_string(), + "exec_command".to_string(), + Some("two".to_string()) + ), + ( + "call_1#2".to_string(), + "exec_command".to_string(), + Some("three".to_string()) + ), + ] + ); + assert_eq!( + tool_results(&detail), + vec![ + ( + "call_1#0".to_string(), + Some("out-one\nexit_code=0".to_string()), + false + ), + ( + "call_1#1".to_string(), + Some("out-two\nexit_code=1".to_string()), + false + ), + ( + "call_1#2".to_string(), + Some("out-three\nexit_code=0".to_string()), + false + ), + ] + ); + + let _ = fs::remove_file(path); + } + + /// The other half of the shape: a `---RESULT n---` header ahead of each + /// output. The counter differs per call, so the repeat is only visible with + /// digits blinded — and each chunk of the group is unwrapped on its own, so + /// an envelope behind a header still reads as a terminal. + #[test] + fn a_numbered_header_per_call_still_splits_per_call() { + let envelope = |out: &str| { + serde_json::json!({ + "chunk_id": "abc123", + "wall_time_seconds": 0.5, + "exit_code": 0, + "original_token_count": 9, + "output": out, + }) + .to_string() + }; + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"})\n]);\nr.forEach((x, i) => { text(`---RESULT ${i + 1}---`); text(JSON.stringify(x)); });\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "---RESULT 1---"}, + {"type": "input_text", "text": envelope("out-one")}, + {"type": "input_text", "text": "---RESULT 2---"}, + {"type": "input_text", "text": envelope("out-two")}, + ]), + ); + let path = write_temp_rollout("code-mode-stride-head", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-stride-head") + .expect("parse ok"); + + assert_eq!( + tool_results(&detail), + vec![ + ( + "call_1#0".to_string(), + Some("---RESULT 1---\nout-one".to_string()), + false + ), + ( + "call_1#1".to_string(), + Some("---RESULT 2---\nout-two".to_string()), + false + ), + ] + ); + + let _ = fs::remove_file(path); + } + + /// The adversarial version of the shape above: the second command's own + /// stdout IS `exit_code=0`, so slot 1 of every assumed pair repeats and the + /// chunk content alone stops telling the two loops apart. Only the script + /// says which it is — two loops, so no grouping. + #[test] + fn a_command_whose_output_mimics_the_marker_does_not_force_a_grouping() { + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"printf one\"}),\n tools.exec_command({cmd:\"printf 'exit_code=0'\"}),\n tools.exec_command({cmd:\"printf three\"})\n]);\nfor (const x of r) text(x.output);\nfor (const x of r) text(`exit_code=${x.exit_code}`);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "one"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "three"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "exit_code=0"}, + ]), + ); + let path = write_temp_rollout("code-mode-stride-mimic", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-stride-mimic") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + let results = tool_results(&detail); + assert_eq!(results.len(), 1); + assert!(results[0] + .1 + .as_deref() + .expect("script output") + .starts_with("one\nexit_code=0\nthree")); + + let _ = fs::remove_file(path); + } + + /// Dividing evenly is NOT the proof. This script prints every output and + /// then every exit code — the same 6 chunks for 3 calls as the interleaved + /// shape above, in an order where grouping them in pairs would pin call 2's + /// output to call 1's card. Only the script says which shape it is. + #[test] + fn chunks_that_merely_divide_evenly_are_not_grouped() { + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nfor (const x of r) text(x.output);\nfor (const x of r) text(`exit_code=${x.exit_code}`);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "out-one"}, + {"type": "input_text", "text": "out-two"}, + {"type": "input_text", "text": "out-three"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "exit_code=0"}, + {"type": "input_text", "text": "exit_code=0"}, + ]), + ); + let path = write_temp_rollout("code-mode-stride-interleaved", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-stride-interleaved") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + + let _ = fs::remove_file(path); + } + + /// What codex sends instead of the chunks when a script's `text()` stream is + /// too long: the whole stream re-rendered as one blob, one line per `text()`, + /// behind a banner declaring how many lines it started with. + fn collapsed_blob(declared: usize, outputs: &[&str]) -> String { + let lines: Vec = outputs + .iter() + .enumerate() + .map(|(index, out)| { + serde_json::json!({ + "chunk_id": format!("chunk{index}"), + "wall_time_seconds": 0.5, + "exit_code": 0, + "original_token_count": 9, + "output": out, + }) + .to_string() + }) + .collect(); + format!( + "Warning: truncated output (original token count: 10644)\nTotal output lines: {declared}\n\n{}", + lines.join("\n") + ) + } + + #[test] + fn a_collapsed_output_blob_splits_back_into_one_card_per_call() { + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nfor (const x of r) text(x);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": collapsed_blob(3, &["out-one", "out-two", "out-three"])}, + ]), + ); + let path = write_temp_rollout("code-mode-collapsed", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-collapsed") + .expect("parse ok"); + + assert_eq!( + tool_uses(&detail), + vec![ + ( + "call_1#0".to_string(), + "exec_command".to_string(), + Some("one".to_string()) + ), + ( + "call_1#1".to_string(), + "exec_command".to_string(), + Some("two".to_string()) + ), + ( + "call_1#2".to_string(), + "exec_command".to_string(), + Some("three".to_string()) + ), + ] + ); + assert_eq!( + tool_results(&detail), + vec![ + ("call_1#0".to_string(), Some("out-one".to_string()), false), + ("call_1#1".to_string(), Some("out-two".to_string()), false), + ("call_1#2".to_string(), Some("out-three".to_string()), false), + ] + ); + + let _ = fs::remove_file(path); + } + + /// One call, one line, and the line is the raw result object — the shape + /// that used to leave a card whose entire body was `{"chunk_id":…}`. + #[test] + fn a_collapsed_single_call_blob_still_shows_its_output() { + let lines = code_mode_rollout( + "const r = await tools.exec_command({cmd:\"cargo test\"});\ntext(r);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 9.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": collapsed_blob(1, &["test result: ok. 42 passed"])}, + ]), + ); + let path = write_temp_rollout("code-mode-collapsed-single", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-collapsed-single") + .expect("parse ok"); + + assert_eq!( + tool_results(&detail), + vec![( + "call_1".to_string(), + Some("test result: ok. 42 passed".to_string()), + false + )] + ); + + let _ = fs::remove_file(path); + } + + /// Past the cap codex drops whole lines — and it drops them from the middle, + /// so a survivor's position no longer names its call. Measured: with a line + /// missing, 13 of 57 checkable lines belong to a different command than + /// their position claims. Splitting anyway would put a command's output + /// under another command's title, so the script card stays. + #[test] + fn a_collapsed_blob_missing_a_line_keeps_the_script_card() { + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nfor (const x of r) text(x);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": collapsed_blob(3, &["out-one", "out-three"])}, + ]), + ); + let path = write_temp_rollout("code-mode-collapsed-lossy", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-collapsed-lossy") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + let results = tool_results(&detail); + assert_eq!(results.len(), 1); + assert!(results[0] + .1 + .as_deref() + .expect("script output") + .starts_with("Warning: truncated output")); + + let _ = fs::remove_file(path); + } + + /// The same banner fronts the far more common case: ONE command whose own + /// stdout was too long. Its lines are stdout, not `text()` results, and + /// splitting them would shred one command's output across every card. + #[test] + fn a_truncated_command_output_is_not_read_as_one_line_per_call() { + let lines = code_mode_rollout( + "const r = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"})\n]);\ntext(r[0].output);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "Warning: truncated output (original token count: 900)\nTotal output lines: 2\n\nfirst line of stdout\nsecond line of stdout"}, + ]), + ); + let path = write_temp_rollout("code-mode-collapsed-stdout", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-collapsed-stdout") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + let results = tool_results(&detail); + assert_eq!(results.len(), 1); + assert!(results[0] + .1 + .as_deref() + .expect("script output") + .contains("first line of stdout\nsecond line of stdout")); + + let _ = fs::remove_file(path); + } + + /// Fewer `text()` chunks than calls means chunk *i* is NOT provably call + /// *i*'s output — keep one script card rather than misattribute. + #[test] + fn code_mode_mismatched_chunks_keep_the_script_card() { + let lines = code_mode_rollout( + "const rs = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"})\n]);\ntext(JSON.stringify(rs));\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n"}, + {"type": "input_text", "text": "[{\"output\":\"a\"},{\"output\":\"b\"}]"}, + ]), + ); + let path = write_temp_rollout("code-mode-mismatch", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-mismatch") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + let card: serde_json::Value = + serde_json::from_str(uses[0].2.as_deref().expect("script card input")) + .expect("script card input is JSON"); + assert_eq!(card["title"], "one"); + assert_eq!(card["call_count"], 2); + assert!(card["source"] + .as_str() + .expect("source") + .contains("Promise.all")); + + let results = tool_results(&detail); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].1.as_deref(), + Some("[{\"output\":\"a\"},{\"output\":\"b\"}]") + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn code_mode_apply_patch_renders_as_a_patch_card() { + let lines = code_mode_rollout( + "const patch = \"*** Begin Patch\\n*** Update File: a.rs\\n@@\\n-old\\n+new\\n*** End Patch\";\nconst result = await tools.apply_patch(patch);\ntext(result);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.2 seconds\nOutput:\n"}, + {"type": "input_text", "text": "Success. Updated the following files:\nM a.rs"}, + ]), + ); + let path = write_temp_rollout("code-mode-patch", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-patch") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, "apply_patch"); + assert!(uses[0] + .2 + .as_deref() + .expect("patch text") + .starts_with("*** Begin Patch")); + + let _ = fs::remove_file(path); + } + + #[test] + fn code_mode_script_failure_marks_the_result_as_an_error() { + let lines = code_mode_rollout( + "const r = await tools.exec_command({cmd: \"rm -rf /tmp/x\"});\ntext(r.output);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "Script error:\nexec_command failed for `rm -rf /tmp/x`"}, + ]), + ); + let path = write_temp_rollout("code-mode-failed", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-failed") + .expect("parse ok"); + + let results = tool_results(&detail); + assert_eq!(results.len(), 1); + assert!(results[0].2, "a failed script must render as an error"); + + let _ = fs::remove_file(path); + } + + /// An interrupted turn never writes the output record; the script card must + /// stay where it was rather than vanish or jump to the end. + #[test] + fn code_mode_call_without_output_keeps_its_script_card() { + let lines = vec![ + rollout_line( + "2026-07-20T08:40:00Z", + "event_msg", + serde_json::json!({"type": "user_message", "message": "go"}), + ), + rollout_line( + "2026-07-20T08:40:01Z", + "response_item", + serde_json::json!({ + "type": "custom_tool_call", + "name": "exec", + "call_id": "call_1", + "input": "const r = await tools.exec_command({cmd: \"sleep 60\"});", + }), + ), + ]; + let path = write_temp_rollout("code-mode-dangling", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-dangling") + .expect("parse ok"); + + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].1, CODEX_SCRIPT_TOOL_NAME); + assert!(tool_results(&detail).is_empty()); + + let _ = fs::remove_file(path); + } + + /// The unified-exec `wait` tool shares code mode's array output shape; it + /// used to render as a raw `[{"text":"Script completed…` JSON blob. + #[test] + fn wait_tool_output_is_joined_text_not_raw_json() { + let lines = vec![ + rollout_line( + "2026-07-20T08:40:00Z", + "event_msg", + serde_json::json!({"type": "user_message", "message": "go"}), + ), + rollout_line( + "2026-07-20T08:40:01Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_w", + "arguments": "{\"cell_id\":\"55\",\"yield_time_ms\":30000}", + }), + ), + rollout_line( + "2026-07-20T08:40:09Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": "call_w", + "output": [ + {"type": "input_text", "text": "Script completed\nWall time 5.6 seconds\nOutput:\n"}, + {"type": "input_text", "text": "test result: ok. 12 passed"}, + ], + }), + ), + ]; + let path = write_temp_rollout("code-mode-wait", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "code-mode-wait") + .expect("parse ok"); + + assert_eq!( + tool_results(&detail), + vec![( + "call_w".to_string(), + Some("test result: ok. 12 passed".to_string()), + false + )] + ); + + let _ = fs::remove_file(path); + } + + /// `wait` / `write_stdin` carry a session id, never a command — which is + /// why their cards used to be titled by the bare tool name. The command + /// that started the session is recovered from the announcement in that + /// command's own output. + fn session_tool_rollout( + exec_output: serde_json::Value, + session_call: serde_json::Value, + ) -> Vec { + let mut lines = background_session_head(exec_output); + lines.push(rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + session_call, + )); + lines + } + + /// `pnpm dev`, answered with whatever it had printed when unified-exec's + /// yield ran out. + fn background_session_head(exec_output: serde_json::Value) -> Vec { + vec![ + rollout_line( + "2026-07-20T08:40:00Z", + "event_msg", + serde_json::json!({"type": "user_message", "message": "go"}), + ), + rollout_line( + "2026-07-20T08:40:01Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "exec_command", + "call_id": "call_e", + "arguments": "{\"cmd\":\"pnpm dev\",\"yield_time_ms\":1000}", + }), + ), + rollout_line( + "2026-07-20T08:40:02Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": "call_e", + "output": exec_output, + }), + ), + ] + } + + /// One `write_stdin` collecting more of a background session's output, and + /// the output it collected. + fn poll_lines(call_id: &str, args: &str, output: serde_json::Value) -> Vec { + vec![ + rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "write_stdin", + "call_id": call_id, + "arguments": args, + }), + ), + rollout_line( + "2026-07-20T08:40:04Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output, + }), + ), + ] + } + + fn parse_lines(lines: &[String], tag: &str) -> crate::models::ConversationDetail { + let path = write_temp_rollout(tag, lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, tag) + .expect("parse ok"); + let _ = fs::remove_file(path); + detail + } + + fn session_tool_input(lines: &[String], tag: &str) -> Option { + tool_uses(&parse_lines(lines, tag)) + .into_iter() + .find(|(id, _, _)| id == "call_s") + .and_then(|(_, _, input)| input) + } + + /// A call that gets a card of its own — here a `wait` that also kills the + /// session — is titled by the command it is acting on, not by the bare tool + /// name. (A call that only collects output gets no card at all; see + /// `polling_a_background_session_appends_to_the_card_of_its_command`.) + #[test] + fn wait_carries_the_command_whose_session_it_polls() { + let lines = session_tool_rollout( + serde_json::json!( + "Chunk ID: 523e44\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\nstarting…" + ), + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_s", + "arguments": "{\"cell_id\":\"22068\",\"terminate\":true,\"yield_time_ms\":30000}", + }), + ); + + let input: serde_json::Value = + serde_json::from_str(&session_tool_input(&lines, "session-wait").expect("input")) + .expect("json args"); + assert_eq!(input["session_command"], "pnpm dev"); + // The original arguments survive untouched next to it. + assert_eq!(input["cell_id"], "22068"); + assert_eq!(input["yield_time_ms"], 30000); + } + + #[test] + fn write_stdin_resolves_a_numeric_session_id_from_a_code_mode_result() { + let lines = session_tool_rollout( + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 1.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "{\"chunk_id\":\"a74f94\",\"session_id\":7106,\"output\":\"\"}"}, + ]), + serde_json::json!({ + "type": "function_call", + "name": "write_stdin", + "call_id": "call_s", + "arguments": "{\"session_id\":7106,\"chars\":\"q\\n\"}", + }), + ); + + let input: serde_json::Value = + serde_json::from_str(&session_tool_input(&lines, "session-stdin").expect("input")) + .expect("json args"); + assert_eq!(input["session_command"], "pnpm dev"); + } + + /// A `wait` / `write_stdin` that only collects output is the earlier command + /// still running — not a second command. Before this, a `cargo test` cut + /// short by unified-exec's yield showed as two adjacent cards with the same + /// title, the first holding a few lines of output and the second holding + /// the rest. + #[test] + fn polling_a_background_session_appends_to_the_card_of_its_command() { + let mut lines = background_session_head(serde_json::json!( + "Chunk ID: 523e44\nWall time: 30.0 seconds\nProcess running with session ID 22068\nOutput:\n Compiling codeg" + )); + // Nothing new yet, and the session says so by naming itself again. + lines.extend(poll_lines( + "call_p1", + "{\"session_id\":22068,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a1\nWall time: 30.0 seconds\nProcess running with session ID 22068\nOutput:\n" + ), + )); + lines.extend(poll_lines( + "call_p2", + "{\"session_id\":22068,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\ntest result: ok. 1 passed" + ), + )); + + let detail = parse_lines(&lines, "session-fold"); + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 1, "polls must not add cards: {uses:?}"); + assert_eq!(uses[0].1, "exec_command"); + + let results = tool_results(&detail); + assert_eq!(results.len(), 1, "{results:?}"); + let output = results[0].1.clone().expect("output"); + assert_eq!(output, " Compiling codeg\ntest result: ok. 1 passed"); + } + + /// The chunk envelope in its object form — what a script that prints its + /// `exec_command` result verbatim leaves behind. Unwrapped to the output it + /// carries, exactly like the string form's header is stripped, so a folded + /// session does not read as JSON followed by terminal output. + #[test] + fn a_code_mode_exec_result_shows_its_output_not_its_envelope() { + let mut lines = code_mode_rollout( + "const r = await tools.exec_command({cmd:\"pnpm dev\"});\ntext(JSON.stringify(r));\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 1.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "{\"chunk_id\":\"6a10a9\",\"wall_time_seconds\":1.0,\"session_id\":75100,\"original_token_count\":0,\"output\":\"\"}"}, + ]), + ); + lines.extend(poll_lines( + "call_p", + "{\"session_id\":75100,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 9.0 seconds\nProcess exited with code 0\nOutput:\n[INFO] Scanning for projects..." + ), + )); + + let detail = parse_lines(&lines, "session-envelope"); + let results = tool_results(&detail); + assert_eq!(results.len(), 1, "{results:?}"); + // The envelope is gone; what the command printed took its place. + assert_eq!( + results[0].1.as_deref(), + Some("[INFO] Scanning for projects...") + ); + } + + /// Keystrokes are an action, not a poll: they earn a card, and everything + /// the session prints afterwards belongs below that card rather than folded + /// back above it. + #[test] + fn keystrokes_get_a_card_and_end_the_folding() { + let mut lines = background_session_head(serde_json::json!( + "Chunk ID: 523e44\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\noverwrite? [y/N]" + )); + lines.extend(poll_lines( + "call_k", + "{\"session_id\":22068,\"chars\":\"y\\n\"}", + serde_json::json!( + "Chunk ID: 9a1\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\nwriting" + ), + )); + lines.extend(poll_lines( + "call_p", + "{\"session_id\":22068,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\ndone" + ), + )); + + let detail = parse_lines(&lines, "session-keys"); + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 3, "{uses:?}"); + assert_eq!(uses[1].0, "call_k"); + assert_eq!(uses[2].0, "call_p"); + + let results = tool_results(&detail); + let origin = results + .iter() + .find(|(id, _, _)| id == "call_e") + .expect("origin result"); + assert_eq!(origin.1.as_deref(), Some("overwrite? [y/N]")); + } + + /// A command's own JSON stdout may well carry `chunk_id` and `output`. + /// Truncating it to the `output` field would silently drop the rest, so the + /// whole envelope shape has to be there before anything is unwrapped. + #[test] + fn json_stdout_that_only_resembles_an_envelope_is_left_whole() { + let payload = + r#"{"chunk_id":"artifact-7","output":"ok","checksum":"abc","files":["a.rs"]}"#; + let lines = code_mode_rollout( + "const r = await tools.exec_command({cmd: \"node build.js\"});\ntext(r.output);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 1.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": payload}, + ]), + ); + + let detail = parse_lines(&lines, "envelope-lookalike"); + assert_eq!( + tool_results(&detail) + .first() + .and_then(|(_, out, _)| out.clone()) + .as_deref(), + Some(payload) + ); + } + + /// One `tools.exec_command` call site driven by a loop is many commands. The + /// first command literal in the source is then just a guess, and a session + /// some later command started would be titled — and folded — under it. + #[test] + fn a_looped_call_site_attributes_no_session() { + let mut lines = code_mode_rollout( + "const cmds = [\"pnpm build\", \"pnpm dev\"];\nfor (const cmd of cmds) {\n const r = await tools.exec_command({cmd});\n text(JSON.stringify(r));\n}\n", + // Two commands ran; the second announced session 4242. + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 1.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "{\"chunk_id\":\"a1\",\"wall_time_seconds\":1.0,\"original_token_count\":0,\"exit_code\":0,\"output\":\"built\"}"}, + {"type": "input_text", "text": "{\"chunk_id\":\"b2\",\"wall_time_seconds\":1.0,\"original_token_count\":0,\"session_id\":4242,\"output\":\"\"}"}, + ]), + ); + lines.extend(poll_lines( + "call_p", + "{\"session_id\":4242,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 1.0 seconds\nProcess exited with code 0\nOutput:\nready" + ), + )); + + let detail = parse_lines(&lines, "looped-site"); + let poll = tool_uses(&detail) + .into_iter() + .find(|(id, _, _)| id == "call_p") + .expect("the poll keeps its card"); + let input = poll.2.expect("input"); + assert!( + !input.contains("session_command"), + "session 4242 was started by one of two commands — attributing it to \ + either is a guess: {input}" + ); + } + + /// The numeric session id and the hex chunk id name the same cell. Once a + /// keystroke earns a card, a poll arriving through the *other* spelling must + /// not still fold its output in above that card. + #[test] + fn an_action_ends_the_folding_for_every_alias_of_its_session() { + let mut lines = background_session_head(serde_json::json!( + "Chunk ID: 523e44\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\noverwrite? [y/N]" + )); + lines.extend(poll_lines( + "call_k", + "{\"session_id\":22068,\"chars\":\"y\\n\"}", + serde_json::json!( + "Chunk ID: 9a1\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\nwriting" + ), + )); + // Same cell, addressed by the chunk id of the announcing envelope. + lines.extend(poll_lines( + "call_p", + "{\"session_id\":\"523e44\",\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\ndone" + ), + )); + + let detail = parse_lines(&lines, "session-alias-boundary"); + let results = tool_results(&detail); + let origin = results + .iter() + .find(|(id, _, _)| id == "call_e") + .expect("origin result"); + assert_eq!(origin.1.as_deref(), Some("overwrite? [y/N]")); + let uses = tool_uses(&detail); + assert_eq!(uses.len(), 3, "the alias poll keeps its own card: {uses:?}"); + } + + /// codex names sessions after pids, and pids come back around. Whichever + /// command announced an id last owns it: its polls must land on its own + /// card, never on the card of the command that held the id before. + #[test] + fn a_reused_session_id_binds_to_whoever_announced_it_last() { + let mut lines = background_session_head(serde_json::json!( + "Chunk ID: 523e44\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\nstarting" + )); + lines.push(rollout_line( + "2026-07-20T08:41:00Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "exec_command", + "call_id": "call_e2", + "arguments": "{\"cmd\":\"cargo watch\",\"yield_time_ms\":1000}", + }), + )); + lines.push(rollout_line( + "2026-07-20T08:41:01Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": "call_e2", + "output": "Chunk ID: 7c11\nWall time: 1.0 seconds\nProcess running with session ID 22068\nOutput:\nwatching", + }), + )); + lines.extend(poll_lines( + "call_p", + "{\"session_id\":22068,\"chars\":\"\"}", + serde_json::json!( + "Chunk ID: 9a2\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\nrebuilt" + ), + )); + + let detail = parse_lines(&lines, "session-reused"); + let results = tool_results(&detail); + let first = results + .iter() + .find(|(id, _, _)| id == "call_e") + .expect("first result"); + let second = results + .iter() + .find(|(id, _, _)| id == "call_e2") + .expect("second result"); + assert_eq!(first.1.as_deref(), Some("starting")); + assert_eq!(second.1.as_deref(), Some("watching\nrebuilt")); + } + + #[test] + fn an_unannounced_session_leaves_the_arguments_alone() { + // The command ran to completion — it never announced a session — so + // there is nothing to attribute this wait to. Inventing a command here + // would be worse than the bare id the card falls back to. + let lines = session_tool_rollout( + serde_json::json!("Chunk ID: 523e44\nWall time: 1.0 seconds\nOutput:\ndone"), + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_s", + "arguments": "{\"cell_id\":\"999\",\"yield_time_ms\":30000}", + }), + ); + + let input = session_tool_input(&lines, "session-unknown").expect("input"); + assert!( + !input.contains("session_command"), + "unexpected attribution: {input}" + ); + } + + /// A code-mode script that answers `Script running with cell ID N` has not + /// finished — the `wait` that later collects cell N carries that script's + /// own return value. It belongs on the script's card, not on a card of its + /// own: before this, the delegation-status card showed only "Script running + /// with cell ID 35" while its actual `{"tasks":[…]}` sat in a separate + /// terminal card further down the transcript. + fn deferred_script_rollout(wait_output: serde_json::Value) -> Vec { + let mut lines = code_mode_rollout( + "const r = await tools.mcp__codeg_mcp__get_delegation_status({task_ids:[\"t1\"],wait_ms:60000});\ntext(JSON.stringify(r));\n", + serde_json::json!("Script running with cell ID 34\nWall time 11.0 seconds\nOutput:\n"), + ); + lines.push(rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_w", + "arguments": "{\"cell_id\":\"34\",\"yield_time_ms\":60000}", + }), + )); + lines.push(rollout_line( + "2026-07-20T08:40:48Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": "call_w", + "output": wait_output, + }), + )); + lines + } + + #[test] + fn a_deferred_scripts_result_lands_on_its_own_card() { + let lines = deferred_script_rollout(serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 44.5 seconds\nOutput:\n"}, + {"type": "input_text", "text": "{\"tasks\":[{\"task_id\":\"t1\",\"child_conversation_id\":2890}]}"}, + ])); + let path = write_temp_rollout("deferred-script", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "deferred-script") + .expect("parse ok"); + + // One card for the call, carrying the result the `wait` collected. + assert_eq!( + tool_uses(&detail) + .into_iter() + .map(|(id, name, _)| (id, name)) + .collect::>(), + vec![( + "call_1".to_string(), + "mcp__codeg_mcp__get_delegation_status".to_string() + )] + ); + assert_eq!( + tool_results(&detail), + vec![( + "call_1".to_string(), + Some( + "{\"tasks\":[{\"task_id\":\"t1\",\"child_conversation_id\":2890}]}".to_string() + ), + false + )] + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn a_script_still_running_after_a_wait_stays_collectable() { + // The wait came back with the script STILL parked: the card keeps the + // running note, and the next wait must still find it. + let lines = deferred_script_rollout(serde_json::json!( + "Script running with cell ID 34\nWall time 60.0 seconds\nOutput:\n" + )); + let path = write_temp_rollout("deferred-still-running", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "deferred-still-running") + .expect("parse ok"); + + assert_eq!(tool_uses(&detail).len(), 1); + assert_eq!( + tool_results(&detail), + vec![( + "call_1".to_string(), + Some("Script running with cell ID 34".to_string()), + false + )] + ); + + let _ = fs::remove_file(path); + } + + /// A `wait` answers with what the script printed SINCE it parked, not with + /// the whole run. Rewriting the cards from that answer alone would drop + /// whatever the script had already printed — and decompose against a chunk + /// count missing its front, so a two-call script could end up undecomposable + /// and leave its `call_1#i` cards without results. + #[test] + fn a_deferred_script_keeps_what_it_printed_before_the_wait() { + let mut lines = code_mode_rollout( + "const a = await tools.exec_command({cmd: \"pnpm build\"});\ntext(a.output);\nconst b = await tools.exec_command({cmd: \"pnpm test\"});\ntext(b.output);\n", + serde_json::json!([ + {"type": "input_text", "text": "Script running with cell ID 34\nWall time 11.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "build finished"}, + ]), + ); + lines.push(rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_w", + "arguments": "{\"cell_id\":\"34\",\"yield_time_ms\":60000}", + }), + )); + lines.push(rollout_line( + "2026-07-20T08:40:48Z", + "response_item", + serde_json::json!({ + "type": "function_call_output", + "call_id": "call_w", + "output": [ + {"type": "input_text", "text": "Script completed\nWall time 45.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "1 passed"}, + ], + }), + )); + + let detail = parse_lines(&lines, "deferred-accumulates"); + // Two chunks across the two answers, two calls: decomposed per call. + assert_eq!( + tool_uses(&detail) + .iter() + .map(|(id, name, _)| (id.as_str(), name.as_str())) + .collect::>(), + vec![("call_1#0", "exec_command"), ("call_1#1", "exec_command")] + ); + assert_eq!( + tool_results(&detail) + .iter() + .map(|(id, out, _)| (id.as_str(), out.as_deref())) + .collect::>(), + vec![ + ("call_1#0", Some("build finished")), + ("call_1#1", Some("1 passed")), + ] + ); + } + + #[test] + fn a_script_cell_is_never_mistaken_for_a_shell_session() { + // `Script running with cell ID N` names the SCRIPT, not a shell inside + // it — so the wait that collects it folds into the script's card and + // contributes no card of its own. Nothing may leak into the shell + // session map either: that cell is not a shell, and the long-running + // call here is not even a command. + let mut lines = code_mode_rollout( + "const r = await tools.mcp__codeg_mcp__ask_user_question({questions: []});\ntext(JSON.stringify(r));\n", + serde_json::json!([ + {"type": "input_text", "text": "Script running with cell ID 15\nWall time 11.0 seconds\nOutput:\n"}, + ]), + ); + lines.push(rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "wait", + "call_id": "call_s", + "arguments": "{\"cell_id\":\"15\"}", + }), + )); + + let path = write_temp_rollout("session-script-cell", &lines); + let detail = CodexParser::new() + .parse_conversation_detail(&path, "session-script-cell") + .expect("parse ok"); + let uses = tool_uses(&detail); + assert_eq!( + uses.iter() + .map(|(id, name, _)| (id.as_str(), name.as_str())) + .collect::>(), + vec![("call_1", "mcp__codeg_mcp__ask_user_question")] + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn code_mode_session_announcements_are_attributed_per_chunk() { + // Two commands in one script, one `text()` chunk each: the second + // command's session id must resolve to the SECOND command. + let mut lines = code_mode_rollout( + "const a = await tools.exec_command({cmd: \"pnpm dev\"});\ntext(JSON.stringify(a));\nconst b = await tools.exec_command({cmd: \"cargo watch\"});\ntext(JSON.stringify(b));\n", + serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 1.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "{\"chunk_id\":\"a1\",\"session_id\":11,\"output\":\"\"}"}, + {"type": "input_text", "text": "{\"chunk_id\":\"b2\",\"session_id\":22,\"output\":\"\"}"}, + ]), + ); + lines.push(rollout_line( + "2026-07-20T08:40:03Z", + "response_item", + serde_json::json!({ + "type": "function_call", + "name": "wait", + // `terminate` keeps it a card of its own — a bare poll would be + // folded into the command's card and have no title to check. + "call_id": "call_s", + "arguments": "{\"cell_id\":\"22\",\"terminate\":true}", + }), + )); + + let input: serde_json::Value = + serde_json::from_str(&session_tool_input(&lines, "session-chunked").expect("input")) + .expect("json args"); + assert_eq!(input["session_command"], "cargo watch"); + } + + } diff --git a/src-tauri/src/parsers/codex_code_mode.rs b/src-tauri/src/parsers/codex_code_mode.rs new file mode 100644 index 0000000000..4b46d2383e --- /dev/null +++ b/src-tauri/src/parsers/codex_code_mode.rs @@ -0,0 +1,2182 @@ +//! Codex "code mode" script unwrapping. +//! +//! Since codex enabled *code mode*, the model no longer emits one rollout +//! record per tool call. Every call is wrapped in a JS script handed to a +//! sandboxed runtime, and the whole script lands in the rollout as a single +//! `custom_tool_call` named `exec`: +//! +//! ```jsonc +//! {"type":"custom_tool_call","name":"exec","call_id":"call_QBS…", +//! "input":"const r = await tools.exec_command({\n cmd: \"git status\",\n …});\ntext(r.output);\n"} +//! {"type":"custom_tool_call_output","call_id":"call_QBS…", +//! "output":[{"type":"input_text","text":"Script completed\nWall time 0.5 seconds\nOutput:\n"}, +//! {"type":"input_text","text":""}]} +//! ``` +//! +//! The live path never sees this shape — codex-acp translates the app-server's +//! semantic thread items (`commandExecution` / `mcpToolCall` / `fileChange`), +//! which are already one-per-call. This module restores the same decomposition +//! for the history path by statically recovering the `tools.()` +//! call sites from the script source, so every existing tool card keeps working. +//! +//! Recovery is deliberately conservative: it is a literal scanner, not a JS +//! interpreter. When *any* call site in a script fails to resolve (shorthand +//! `{cmd, workdir}` destructuring, `.map(c => tools.exec_command(c))`, template +//! interpolation, …) the whole script falls back to a single script card rather +//! than guessing. + +use std::ops::Range; +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::{Map, Value}; + +/// Codex's code-mode wrapper tool name (`custom_tool_call.name`). +pub const CODE_MODE_TOOL_NAME: &str = "exec"; + +/// Synthetic tool name for the fallback card that renders the raw script. +/// Deliberately NOT `exec`: the frontend's freeform matcher collapses +/// `exec(ute)?` to "bash", which would render JS as a shell command. +pub const CODEX_SCRIPT_TOOL_NAME: &str = "codex_script"; + +/// True for the code-mode wrapper `custom_tool_call`. +pub fn is_code_mode_call(tool_name: &str) -> bool { + tool_name == CODE_MODE_TOOL_NAME +} + +/// One `tools.()` call recovered from a code-mode script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeModeCall { + /// The inner tool's own name, exactly as codex named it before code mode + /// (`exec_command`, `apply_patch`, `update_plan`, `mcp__srv__tool`, …). + pub tool_name: String, + /// `input_preview` in the pre-code-mode shape, so the frontend's existing + /// per-tool cards match without any classification change. + pub input_preview: String, + /// Whether the arguments were written as a literal AT the call site, rather + /// than resolved through an identifier's declaration. Only the inline form + /// proves the call ran with these arguments *every* time: a variable can be + /// reassigned between iterations (`job.cmd = cmd` inside a loop), and the + /// declaration this scanner reads is only its first value. Attribution of a + /// background session depends on that proof; the card preview does not. + pub args_inline: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeModeScript { + /// `Some` only when EVERY call site in the script resolved. `None` means + /// the caller must fall back to the script card. + pub calls: Option>, + /// Human title for the script card (best effort, survives resolve failure). + pub summary: Option, + /// Number of `tools.*` call sites detected, resolved or not. + pub call_sites: usize, + /// How many `text()` calls the script makes per result, when they provably + /// run as one group per call in call order: `Some(n)` when the script has + /// exactly ONE loop that walks the `Promise.all` results from the front and + /// all n of its `text()` calls sit inside that loop's body. + /// + /// `None` for everything else, because everything else can reorder or + /// regroup the chunks. `for (x of r) text(x.output)` followed by + /// `for (x of r) text(x.exit_code)` emits exactly as many chunks as one loop + /// printing both, but they arrive pass by pass, so slicing them into + /// per-call groups would pin each command's output to the previous + /// command's card. Hoisting the two `text()` calls into helpers hides that + /// from any lexical test, which is why the loop body — not the calls' + /// neighbourhood — is what has to contain them. `r.toReversed()` and + /// `r[r.length - 1 - i]` walk the same results in the wrong order, so the + /// iterable has to be the bare binding and the index has to be the loop's + /// own counter. + pub text_run: Option, +} + +/// Terminal state reported by the code-mode output envelope's first line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScriptStatus { + Completed, + /// The script left a background cell running; a later `wait` collects it. + Running, + Failed, + Aborted, + /// No recognizable envelope (older codex, or a hand-rolled output). + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeModeOutput { + pub status: ScriptStatus, + /// One entry per `text()` call the script made, envelope header removed. + pub chunks: Vec, + /// Envelope line worth keeping (still-running cell id, abort reason). + pub note: Option, +} + +impl CodeModeOutput { + /// All chunks as one blob, for the paths that do not split per call. + pub fn joined(&self) -> String { + self.chunks.join("\n") + } + + pub fn is_error(&self) -> bool { + matches!(self.status, ScriptStatus::Failed | ScriptStatus::Aborted) + } + + pub fn is_empty(&self) -> bool { + self.chunks.iter().all(|c| c.trim().is_empty()) && self.note.is_none() + } +} + +/// Attach the envelope note (if any) to an output blob. +pub fn with_note(output: Option, note: Option<&str>) -> Option { + match (output, note) { + (Some(text), Some(note)) if text.trim().is_empty() => Some(note.to_string()), + (Some(text), Some(note)) => Some(format!("{text}\n\n{note}")), + (Some(text), None) => Some(text), + (None, Some(note)) => Some(note.to_string()), + (None, None) => None, + } +} + +/// `input_preview` payload for the fallback script card. The frontend reads +/// `source` (rendered as a JS code block), `title` and `call_count`. +pub fn script_card_input(source: &str, summary: Option<&str>, call_sites: usize) -> String { + let mut map = Map::new(); + map.insert("source".to_string(), Value::String(source.to_string())); + if let Some(title) = summary { + map.insert("title".to_string(), Value::String(title.to_string())); + } + map.insert("call_count".to_string(), Value::from(call_sites as u64)); + Value::Object(map).to_string() +} + +// ── script → calls ─────────────────────────────────────────────────────── + +struct ToolCallSite { + tool_name: String, + /// Byte index just past the opening `(`. + args_at: usize, +} + +pub fn parse_code_mode_script(src: &str) -> CodeModeScript { + let sites = find_tool_call_sites(src); + + let mut calls = Vec::with_capacity(sites.len()); + let mut resolved = !sites.is_empty(); + for site in &sites { + match resolve_call(src, site) { + Some(call) => calls.push(call), + None => { + resolved = false; + break; + } + } + } + + let summary = best_effort_summary(src, if resolved { &calls } else { &[] }, &sites); + CodeModeScript { + calls: if resolved { Some(calls) } else { None }, + summary, + call_sites: sites.len(), + text_run: find_text_run(src), + } +} + +/// `const results = await Promise.all(` — the binding every parallel script +/// opens with, and the only handle on the results array the loop can walk. +static RESULTS_BINDING: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*await\s+Promise\s*\.\s*all\s*\(") + .expect("static regex") +}); + +/// See `CodeModeScript::text_run`. +/// +/// This is a whitelist, and deliberately a narrow one. Earlier attempts asked +/// "does the script do anything that would break the grouping?" and kept +/// losing: two passes, then helper indirection, then `r.toReversed()`, then +/// `r.reverse()` before the loop, then a mutation hidden in a `${…}` hole, then +/// `forEach`'s third argument aliasing the array, then a deferred task per +/// iteration, then `++i` inside the body. There is no end to that list — the +/// scripts are JavaScript, and no lexical denylist covers a language. +/// +/// So the question is inverted: the script has to BE the shape this proof +/// understands, and anything else keeps its script card. Measured on the newest +/// 400 rollouts, 260 of the 274 scripts that print a fixed multiple of chunks +/// per call already are exactly that shape, so the narrowness costs almost +/// nothing. +fn find_text_run(src: &str) -> Option { + let binding = RESULTS_BINDING.captures(src)?; + let results = binding.get(1)?.as_str(); + let declaration = binding_statement(src, binding.get(0)?.range())?; + let walk = results_loop(src, results)?; + + // Nothing outside the declaration and the loop. Every bypass so far needed + // somewhere to put a statement — a helper, a `r.reverse()`, a bare template + // whose hole did the damage — and this is that somewhere. + if !nothing_else_in_script(src, &declaration, &walk.statement) { + return None; + } + + // `text()` may only be called from the loop body. One inside the + // declaration — reachable through a `${…}` hole in a command string — would + // print a chunk before the loop and shift every group by one. + for gap in [0..walk.body.start, walk.body.end..src.len()] { + if code_regions(src, gap)? + .iter() + .any(|region| calls_text(src, region)) + { + return None; + } + } + + let grammar = BodyGrammar { + results, + binds: &walk.binds, + counter: walk.counter.as_deref(), + }; + let calls = text_calls_in_body(src, &walk.body, &grammar)?; + (calls > 0).then_some(calls) +} + +/// The span of the whole `const results = await Promise.all(…);` statement, +/// given the span of the header match — which ends just past its `(`. +/// +/// The argument has to be one array literal holding nothing but the calls +/// themselves. The calls are recovered by reading the source in order, so the +/// results array has to hold their results in that same order — and anything +/// applied to the array breaks that. `Promise.all([…].reverse())` does it +/// outright; `[a, ...[b, c].reverse()]` does it from the inside, which is why +/// the elements are checked and not just the brackets. +fn binding_statement(src: &str, header: Range) -> Option> { + let b = src.as_bytes(); + let elements = skip_ws_and_comments(b, header.end); + if b.get(elements) != Some(&b'[') { + return None; + } + let array_end = scan_balanced(b, elements)?; + if !array_is_tool_calls(src, elements + 1..array_end - 1) { + return None; + } + let close = skip_ws_and_comments(b, array_end); + if b.get(close) != Some(&b')') { + return None; + } + let mut end = skip_ws_and_comments(b, close + 1); + if b.get(end) == Some(&b';') { + end += 1; + } + Some(header.start..end) +} + +/// Whether `elements` — the inside of the `Promise.all` array — is a plain +/// comma-separated list of `tools.…(…)` calls, one per element. +/// +/// Element *i* has to be the call recovered *i*-th, and a spread is enough to +/// break that without touching the outer array. +fn array_is_tool_calls(src: &str, elements: Range) -> bool { + let b = src.as_bytes(); + let mut i = skip_ws_and_comments(b, elements.start); + let mut separated = true; + while i < elements.end { + if !separated { + if b.get(i) != Some(&b',') { + return false; + } + i = skip_ws_and_comments(b, i + 1); + separated = true; + continue; + } + if !src.get(i..).is_some_and(|rest| rest.starts_with("tools.")) { + return false; + } + let mut name = i + "tools.".len(); + while name < elements.end && is_ident_byte(b[name]) { + name += 1; + } + let open = skip_ws_and_comments(b, name); + if name == i + "tools.".len() || b.get(open) != Some(&b'(') { + return false; + } + let Some(close) = scan_parens(b, open) else { + return false; + }; + i = skip_ws_and_comments(b, close); + separated = false; + } + true +} + +/// The ONE loop that walks `results` from the front. +struct ResultsLoop { + /// The whole loop statement, header and body. + statement: Range, + /// Inside the body — for `forEach`, inside the callback's block. + body: Range, + /// The counter, for the index form. The only form whose body may name the + /// results array at all, and then only as `results[counter]`. + counter: Option, + /// What the loop hands its body: the `for … of` variable, or the callback's + /// parameters. + binds: Vec, +} + +/// `None` when the script has no such loop, or more than one. +/// +/// Three headers, and nothing else: `for (const x of results)`, +/// `results.forEach(`, and `for (let i = 0; i < results.length; i++)`. Each +/// hands its body the *i*-th result in order — the first two by binding it, the +/// third by an ascending counter. The iterable has to be the bare binding: +/// `results.toReversed()` walks the same values in the wrong order. +fn results_loop(src: &str, results: &str) -> Option { + let b = src.as_bytes(); + let name = regex::escape(results); + let for_of = Regex::new(&format!( + r"\bfor\s*\(\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+of\s+{name}\s*\)" + )) + .ok()?; + let for_each = Regex::new(&format!(r"\b{name}\s*\.\s*forEach\s*\(")).ok()?; + let for_index = Regex::new(&format!( + r"\bfor\s*\(\s*(?:let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*0\s*;\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*<\s*{name}\s*\.\s*length\s*;\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\+\+\s*\)" + )) + .ok()?; + + // `header`, then what the loop binds, then whether it is the callback form. + let mut found: Option<(Range, Vec, Option, bool)> = None; + let mut hits = 0usize; + for caps in for_of.captures_iter(src) { + hits += 1; + found = Some(( + caps.get(0)?.range(), + vec![caps.get(1)?.as_str().to_string()], + None, + false, + )); + } + for m in for_each.find_iter(src) { + hits += 1; + found = Some((m.range(), Vec::new(), None, true)); + } + for caps in for_index.captures_iter(src) { + hits += 1; + let counter = caps.get(1)?.as_str(); + // The regex crate has no backreferences; the three spellings of the + // counter have to be compared here. + if caps.get(2)?.as_str() != counter || caps.get(3)?.as_str() != counter { + return None; + } + found = Some(( + caps.get(0)?.range(), + vec![counter.to_string()], + Some(counter.to_string()), + false, + )); + } + if hits != 1 { + return None; + } + + let (header, mut binds, counter, callback) = found?; + if callback { + // `forEach` hands its callback the array itself as a third argument, so + // a callback that takes one holds an alias this proof does not track: + // `(x, i, all) => { if (!i) all.reverse(); … }` turns around the very + // array the header proved was in order. Only a plain one- or + // two-parameter arrow with a block body is accepted. + let call_end = scan_parens(b, header.end - 1)?; + let (params, arrow_at) = arrow_parameters(src, header.end)?; + binds.extend(params); + let body = block_after(src, arrow_at)?; + // `forEach` takes a second argument, and it is evaluated before the + // first iteration: `r.forEach(x => {…}, r.reverse())` turns the array + // around on its way in. Nothing may follow the callback. + if skip_ws_and_comments(b, body.end + 1) != call_end - 1 { + return None; + } + let mut end = skip_ws_and_comments(b, call_end); + if b.get(end) == Some(&b';') { + end += 1; + } + return Some(ResultsLoop { + statement: header.start..end, + body, + counter, + binds, + }); + } + + let body = block_after(src, header.end)?; + Some(ResultsLoop { + statement: header.start..body.end + 1, + body, + counter, + binds, + }) +} + +/// The parameters of the arrow function starting at `at`, and the offset just +/// past its `=>`. `None` when it is not a plain arrow of one or two simple +/// parameters — a `function` expression, a destructuring pattern, or the +/// three-parameter form that receives the array. +fn arrow_parameters(src: &str, at: usize) -> Option<(Vec, usize)> { + static ARROW: LazyLock = LazyLock::new(|| { + Regex::new( + r"^\s*(?:\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*)?\)|([A-Za-z_$][A-Za-z0-9_$]*))\s*=>", + ) + .expect("static regex") + }); + let caps = ARROW.captures(src.get(at..)?)?; + let params = (1..=3) + .filter_map(|group| caps.get(group)) + .map(|m| m.as_str().to_string()) + .collect(); + Some((params, at + caps.get(0)?.end())) +} + +/// Whether everything outside the two spans is whitespace, comments, or `;`. +fn nothing_else_in_script(src: &str, first: &Range, second: &Range) -> bool { + let (first, second) = if first.start <= second.start { + (first, second) + } else { + (second, first) + }; + if first.end > second.start { + return false; + } + [0..first.start, first.end..second.start, second.end..src.len()] + .into_iter() + .all(|gap| is_filler(src, gap)) +} + +fn is_filler(src: &str, gap: Range) -> bool { + let b = src.as_bytes(); + let mut i = gap.start; + while i < gap.end { + let next = skip_ws_and_comments(b, i); + if next > i { + i = next; + continue; + } + if b[i] == b';' { + i += 1; + continue; + } + return false; + } + true +} + +/// How many `text(…)` calls the loop body is made of, or `None` if it is made +/// of anything else at all. +/// +/// The body is required to be a run of `text(…)` statements whose arguments are +/// built from literals, the names the loop binds, property reads, +/// `results[counter]`, and `JSON.stringify(…)`. Nothing else parses: no +/// assignment, no `++`, no `if`, no arrow, no call to anything else, no regex +/// literal, no `await`. Those are not enumerated — they are simply not in the +/// grammar. +fn text_calls_in_body(src: &str, body: &Range, ctx: &BodyGrammar) -> Option { + scan_body(src, body.clone(), 0, ctx) +} + +/// What a loop body is allowed to name. +struct BodyGrammar<'a> { + /// The results binding — readable only as `results[counter]`. + results: &'a str, + /// What the loop hands the body, plus the counter. + binds: &'a [String], + counter: Option<&'a str>, +} + +/// Walk `range` as JavaScript tokens, returning how many `text(…)` calls it is +/// made of. +/// +/// `depth` is the parenthesis nesting. At 0 — statement position — the ONLY +/// thing that parses is `text(`, `)` and `;`, which is what keeps `++i;`, +/// `if (…)`, `defer(…)`, and every assignment out without naming any of them. +/// Inside a `text(…)` argument the grammar opens up to exactly what a value can +/// be: the names the loop binds, property reads, `results[counter]`, literals, +/// `JSON.stringify(…)`, and the operators that appear in real scripts. +fn scan_body(src: &str, range: Range, start_depth: usize, ctx: &BodyGrammar) -> Option { + let b = src.as_bytes(); + let mut calls = 0usize; + let mut depth = start_depth; + let mut previous: Option<&str> = None; + let mut after_dot = false; + // Whether the token just read can end a value, which is what tells a + // template literal apart from a tagged one: `` tag`…` `` runs `tag` with + // the pieces, so it is a call, not a string. + let mut ends_value = false; + let mut i = range.start; + + while i < range.end { + let skipped = skip_ws_and_comments(b, i); + if skipped > i { + i = skipped; + continue; + } + let c = b[i]; + + if is_ident_start(c) { + let mut j = i; + while j < range.end && is_ident_byte(b[j]) { + j += 1; + } + let word = &src[i..j]; + if depth == 0 { + // `text` and then its `(`, with nothing in between. `text;` and + // `text\n(…)` are the same program to JavaScript but not to a + // reader, and the second is a call to whatever `(…)` builds. + if word != "text" || after_dot || b.get(skip_ws_and_comments(b, j)) != Some(&b'(') + { + return None; + } + calls += 1; + } else if !after_dot + && word != "JSON" + && word != ctx.results + && !ctx.binds.iter().any(|bound| bound.as_str() == word) + { + return None; + } + previous = Some(word); + after_dot = false; + ends_value = true; + i = j; + continue; + } + + if depth > 0 && c.is_ascii_digit() { + while i < range.end && (b[i].is_ascii_digit() || b[i] == b'.') { + i += 1; + } + previous = None; + ends_value = true; + continue; + } + + if depth > 0 && matches!(c, b'"' | b'\'') { + i = scan_string(b, i); + if i > range.end { + return None; + } + previous = None; + ends_value = true; + continue; + } + + if depth > 0 && c == b'`' { + // Only a string, never a tag. + if ends_value { + return None; + } + let (end, in_holes) = scan_template_value(src, i, range.end, ctx)?; + calls += in_holes; + i = end; + previous = None; + ends_value = true; + continue; + } + + match c { + // A call, and only to the two things that can neither reorder nor + // mutate anything. + b'(' => { + if !matches!(previous, Some("text") | Some("stringify")) { + return None; + } + depth += 1; + previous = None; + ends_value = false; + } + b')' => { + depth = depth.checked_sub(1)?; + previous = None; + ends_value = true; + } + b';' if depth == 0 => ends_value = false, + // The results array is readable only at the loop's own counter. + b'[' if depth > 0 => { + let counter = ctx.counter?; + if previous != Some(ctx.results) { + return None; + } + let at = skip_ws_and_comments(b, i + 1); + let after = at + counter.len(); + if src.get(at..after) != Some(counter) + || b.get(after).copied().is_some_and(is_ident_byte) + { + return None; + } + let close = skip_ws_and_comments(b, after); + if b.get(close) != Some(&b']') { + return None; + } + i = close; + previous = None; + ends_value = true; + } + b'.' if depth > 0 => after_dot = true, + // `++` and `--` are updates, not two operators in a row. + b'+' if depth > 0 && b.get(i + 1) == Some(&b'+') => return None, + b',' | b'?' | b':' | b'+' | b'|' | b'&' | b'!' if depth > 0 => { + previous = None; + ends_value = false; + } + _ => return None, + } + i += 1; + } + + (depth == start_depth).then_some(calls) +} + +/// Walk the template literal at `at`, applying the value grammar to each +/// `${ … }` hole. Returns the offset just past the closing backtick, and how +/// many `text(…)` calls the holes contained — always zero in practice, since +/// `text` is not a value the grammar admits. +/// +/// A regex literal could hide an unbalanced brace from the hole scan — +/// `` `${/[}]/.test("}") && r.reverse()}` `` ends the hole at the character +/// class. What spills out stays hidden, but the truncated hole still begins with +/// the `/` that opened the regex, and `/` is not in the grammar, so the script +/// is rejected before any of it matters. +fn scan_template_value( + src: &str, + at: usize, + limit: usize, + ctx: &BodyGrammar, +) -> Option<(usize, usize)> { + let b = src.as_bytes(); + let mut calls = 0usize; + let mut i = at + 1; + while i < limit { + match b[i] { + b'\\' => i += 2, + b'`' => return Some((i + 1, calls)), + b'$' if b.get(i + 1) == Some(&b'{') => { + let end = scan_balanced(b, i + 1)?; + if end > limit { + return None; + } + calls += scan_body(src, i + 2..end - 1, 1, ctx)?; + i = end; + } + _ => i += 1, + } + } + None +} + +/// The parts of `range` that are code: everything outside string literals, plus +/// the inside of each `${ … }` hole of a template, which is code too. +/// +/// `None` when a literal never closes — an unterminated string means the rest of +/// the body cannot be read with any confidence, so nothing is claimed about it. +fn code_regions(src: &str, range: Range) -> Option>> { + let b = src.as_bytes(); + let mut regions = Vec::new(); + let mut start = range.start; + let mut i = range.start; + while i < range.end { + match b[i] { + b'"' | b'\'' => { + regions.push(start..i); + i = scan_string(b, i); + if i > range.end { + return None; + } + start = i; + } + b'`' => { + regions.push(start..i); + i = template_holes(src, i, range.end, &mut regions)?; + start = i; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + regions.push(start..i); + i = skip_ws_and_comments(b, i); + start = i; + } + _ => i += 1, + } + } + regions.push(start..range.end); + Some(regions) +} + +/// Push the `${ … }` holes of the template literal at `at`, returning the offset +/// just past its closing backtick. +/// +/// The hole ends at the `}` that matches its `{`, found by counting braces while +/// skipping nested literals. A regex literal could hide an unbalanced brace from +/// that count — `` `${/[}]/.test("}") && r.reverse()}` `` — but a regex literal +/// is not in the grammar the caller accepts, so a hole that ends in the wrong +/// place cannot end anywhere useful: whatever spills out of it is read as code +/// and rejected. +fn template_holes( + src: &str, + at: usize, + limit: usize, + holes: &mut Vec>, +) -> Option { + let b = src.as_bytes(); + let mut i = at + 1; + while i < limit { + match b[i] { + b'\\' => i += 2, + b'`' => return Some(i + 1), + b'$' if b.get(i + 1) == Some(&b'{') => { + let end = scan_balanced(b, i + 1)?; + if end > limit { + return None; + } + holes.push(i + 2..end - 1); + i = end; + } + _ => i += 1, + } + } + None +} + +/// Whether this stretch of code calls `text(…)`. An XPath like +/// `//div[@id="content"]//text()` inside a `cmd:` literal is not a call — 11 of +/// the candidate scripts in the sampled transcripts pass exactly that to +/// `xmllint` — which is why callers hand this code regions, never raw source. +fn calls_text(src: &str, region: &Range) -> bool { + let b = src.as_bytes(); + let mut i = region.start; + while i < region.end { + let skipped = skip_ws_and_comments(b, i); + if skipped > i { + i = skipped; + continue; + } + if is_ident_start(b[i]) { + let mut j = i; + while j < region.end && is_ident_byte(b[j]) { + j += 1; + } + if &src[i..j] == "text" + && !preceded_by_ident(b, i) + && b.get(skip_ws_and_comments(b, j)) == Some(&b'(') + { + return true; + } + i = j; + continue; + } + i += 1; + } + false +} + +/// The span inside the `{ … }` that follows `at`. A single-statement body has +/// no braces to hold the several calls this is looking for, so it is not +/// accepted. +fn block_after(src: &str, at: usize) -> Option> { + let b = src.as_bytes(); + let start = skip_ws_and_comments(b, at); + if b.get(start) != Some(&b'{') { + return None; + } + let end = scan_balanced(b, start)?; + Some(start + 1..end - 1) +} + +/// `scan_balanced` for `(`/`)`, which it does not count. +fn scan_parens(b: &[u8], i: usize) -> Option { + let mut depth = 0usize; + let mut i = i; + while i < b.len() { + match b[i] { + b'"' | b'\'' | b'`' => { + i = scan_string(b, i); + continue; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_ws_and_comments(b, i); + continue; + } + b'(' => depth += 1, + b')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i + 1); + } + } + _ => {} + } + i += 1; + } + None +} + +fn find_tool_call_sites(src: &str) -> Vec { + let b = src.as_bytes(); + let mut sites = Vec::new(); + let mut i = 0usize; + + while i < b.len() { + match b[i] { + b'"' | b'\'' | b'`' => { + i = scan_string(b, i); + continue; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_ws_and_comments(b, i); + continue; + } + b't' if src[i..].starts_with("tools.") && !preceded_by_ident(b, i) => { + let name_start = i + "tools.".len(); + let mut j = name_start; + while j < b.len() && is_ident_byte(b[j]) { + j += 1; + } + if j > name_start { + let k = skip_ws_and_comments(b, j); + if b.get(k) == Some(&b'(') { + sites.push(ToolCallSite { + tool_name: src[name_start..j].to_string(), + args_at: k + 1, + }); + i = k + 1; + continue; + } + } + i = j.max(i + 1); + continue; + } + _ => i += 1, + } + } + + sites +} + +fn resolve_call(src: &str, site: &ToolCallSite) -> Option { + let (args, args_inline) = resolve_call_args(src, site.args_at)?; + let input_preview = call_input_preview(&site.tool_name, &args)?; + Some(CodeModeCall { + tool_name: site.tool_name.clone(), + input_preview, + args_inline, + }) +} + +/// The call's arguments, and whether they were literal at the call site. +fn resolve_call_args(src: &str, args_at: usize) -> Option<(Value, bool)> { + let b = src.as_bytes(); + let start = skip_ws_and_comments(b, args_at); + // Whatever the argument starts with, it has to BE the whole argument. The + // literal in `tools.exec_command({cmd: "pnpm build"} && job)` is not what + // runs, and reading it as one would credit `job`'s command — and any + // background session it announces — to text that never executed. All 19183 + // inline call sites in the sampled transcripts close right after the + // literal, so the check costs no recovery. + let ends_argument = |at: usize| b.get(skip_ws_and_comments(b, at)) == Some(&b')'); + match b.get(start)? { + b'{' | b'[' => { + let end = scan_balanced(b, start)?; + if !ends_argument(end) { + return None; + } + js_literal_to_json(src.get(start..end)?).map(|args| (args, true)) + } + c if is_ident_start(*c) => { + let mut j = start; + while j < b.len() && is_ident_byte(b[j]) { + j += 1; + } + let ident = src.get(start..j)?; + // Only a bare `tools.foo(ident)` is resolvable; anything else + // (member access, call, arithmetic) needs a real interpreter. + if !ends_argument(j) { + return None; + } + resolve_declaration(src, ident).map(|args| (args, false)) + } + _ => None, + } +} + +/// Rebuild the `input_preview` codex wrote before code mode, so the existing +/// per-tool renderers match verbatim. Verified against May/June rollouts: +/// `exec_command` carried the bare command, `apply_patch` the bare patch text, +/// everything else the JSON argument object. +fn call_input_preview(tool_name: &str, args: &Value) -> Option { + match tool_name { + "exec_command" => { + let cmd = args + .get("cmd") + .or_else(|| args.get("command")) + .and_then(|v| v.as_str())?; + Some(cmd.to_string()) + } + "apply_patch" => { + if let Some(text) = args.as_str() { + return Some(text.to_string()); + } + let patch = args + .get("patch") + .or_else(|| args.get("input")) + .and_then(|v| v.as_str())?; + Some(patch.to_string()) + } + _ => match args { + Value::String(s) => Some(s.clone()), + Value::Object(_) | Value::Array(_) => Some(args.to_string()), + _ => None, + }, + } +} + +/// Resolve `const|let|var = ` declared anywhere in the script. +/// Covers the `const patch = "*** Begin Patch…"; tools.apply_patch(patch)` shape. +fn resolve_declaration(src: &str, ident: &str) -> Option { + let b = src.as_bytes(); + let mut i = 0usize; + + while i < b.len() { + match b[i] { + b'"' | b'\'' | b'`' => { + i = scan_string(b, i); + continue; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_ws_and_comments(b, i); + continue; + } + _ => {} + } + if let Some(value) = try_declaration_at(src, i, ident) { + return Some(value); + } + i += 1; + } + + None +} + +fn try_declaration_at(src: &str, i: usize, ident: &str) -> Option { + let b = src.as_bytes(); + if preceded_by_ident(b, i) { + return None; + } + let rest = src.get(i..)?; + let keyword = ["const", "let", "var"] + .into_iter() + .find(|kw| rest.starts_with(kw))?; + + let mut j = i + keyword.len(); + if !b.get(j).is_some_and(|c| c.is_ascii_whitespace()) { + return None; + } + j = skip_ws_and_comments(b, j); + if !src.get(j..)?.starts_with(ident) { + return None; + } + j += ident.len(); + if b.get(j).is_some_and(|c| is_ident_byte(*c)) { + return None; + } + j = skip_ws_and_comments(b, j); + if b.get(j) != Some(&b'=') || b.get(j + 1) == Some(&b'=') { + return None; + } + + let value_at = skip_ws_and_comments(b, j + 1); + match b.get(value_at)? { + b'"' | b'\'' | b'`' => { + let end = scan_string(b, value_at); + js_literal_to_json(src.get(value_at..end)?) + } + b'{' | b'[' => { + let end = scan_balanced(b, value_at)?; + js_literal_to_json(src.get(value_at..end)?) + } + _ => None, + } +} + +/// Title for the script card. Prefers a resolved command, then any literal +/// `cmd:` in the source (so the destructuring shapes still get a real title), +/// then the first tool name. +fn best_effort_summary( + src: &str, + calls: &[CodeModeCall], + sites: &[ToolCallSite], +) -> Option { + if let Some(call) = calls.iter().find(|c| c.tool_name == "exec_command") { + return first_line(&call.input_preview); + } + if let Some(cmd) = first_command_literal(src) { + return first_line(&cmd); + } + if let Some(call) = calls.first() { + return Some(call.tool_name.clone()); + } + sites.first().map(|s| s.tool_name.clone()) +} + +fn first_line(text: &str) -> Option { + let line = text.lines().find(|l| !l.trim().is_empty())?.trim(); + if line.is_empty() { + None + } else { + Some(line.to_string()) + } +} + +/// First `cmd: "…"` / `command: "…"` string literal in the script. +fn first_command_literal(src: &str) -> Option { + let b = src.as_bytes(); + let mut i = 0usize; + + while i < b.len() { + match b[i] { + b'"' | b'\'' | b'`' => { + // A quoted key is still a key: peek before skipping the string. + let end = scan_string(b, i); + if let Some(key) = src + .get(i..end) + .and_then(|raw| decode_js_string(raw, b[i])) + .filter(|key| key == "cmd" || key == "command") + { + let _ = key; + if let Some(value) = command_value_after(src, end) { + return Some(value); + } + } + i = end; + continue; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_ws_and_comments(b, i); + continue; + } + b'c' if !preceded_by_ident(b, i) => { + let mut j = i; + while j < b.len() && is_ident_byte(b[j]) { + j += 1; + } + let word = src.get(i..j).unwrap_or(""); + if word == "cmd" || word == "command" { + if let Some(value) = command_value_after(src, j) { + return Some(value); + } + } + i = j.max(i + 1); + continue; + } + _ => i += 1, + } + } + + None +} + +fn command_value_after(src: &str, key_end: usize) -> Option { + let b = src.as_bytes(); + let colon = skip_ws_and_comments(b, key_end); + if b.get(colon) != Some(&b':') { + return None; + } + let value_at = skip_ws_and_comments(b, colon + 1); + match b.get(value_at)? { + q @ (b'"' | b'\'' | b'`') => { + let end = scan_string(b, value_at); + decode_js_string(src.get(value_at..end)?, *q) + } + _ => None, + } +} + +// ── output envelope ────────────────────────────────────────────────────── + +const HEADER_COMPLETED: &str = "Script completed"; +const HEADER_FAILED: &str = "Script failed"; +const HEADER_RUNNING: &str = "Script running with cell ID"; +const HEADER_ABORTED: &str = "aborted by user"; + +/// Split a code-mode `output` into per-`text()` chunks with the +/// `Script …/Wall time …/Output:` envelope removed. +/// +/// Codex writes the output as an ARRAY of `{type:"input_text", text}` parts — +/// serializing that array as JSON is what leaked raw `[{"text":"Script +/// completed…` into the result panel. +pub fn split_code_mode_output(output: Option<&Value>) -> CodeModeOutput { + let mut chunks = collect_output_chunks(output); + let mut status = ScriptStatus::Unknown; + let mut note = None; + + if let Some(first) = chunks.first_mut() { + if let Some(header) = strip_script_header(first) { + status = header.status; + note = header.note; + *first = header.rest; + } + } + if chunks.first().is_some_and(|c| c.is_empty()) { + chunks.remove(0); + } + + CodeModeOutput { + status, + chunks, + note, + } +} + +fn collect_output_chunks(output: Option<&Value>) -> Vec { + match output { + None | Some(Value::Null) => Vec::new(), + Some(Value::String(s)) => vec![s.clone()], + Some(Value::Array(items)) => items.iter().filter_map(chunk_text).collect(), + Some(other) => chunk_text(other).into_iter().collect(), + } +} + +fn chunk_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(s) => Some(s.clone()), + Value::Object(map) => match map.get("text").and_then(|t| t.as_str()) { + Some(text) => Some(text.to_string()), + None => Some(value.to_string()), + }, + other => Some(other.to_string()), + } +} + +struct StrippedHeader { + status: ScriptStatus, + note: Option, + rest: String, +} + +fn strip_script_header(text: &str) -> Option { + let (first, after) = split_first_line(text); + let trimmed = first.trim_end(); + + let (status, note) = if trimmed == HEADER_COMPLETED { + (ScriptStatus::Completed, None) + } else if trimmed == HEADER_FAILED { + (ScriptStatus::Failed, None) + } else if trimmed.starts_with(HEADER_RUNNING) { + (ScriptStatus::Running, Some(trimmed.to_string())) + } else if trimmed.to_ascii_lowercase().starts_with(HEADER_ABORTED) { + (ScriptStatus::Aborted, Some(trimmed.to_string())) + } else { + return None; + }; + + let rest = drop_line_if(after, |line| { + line.to_ascii_lowercase().starts_with("wall time") + }); + let rest = drop_line_if(rest, |line| line.eq_ignore_ascii_case("output:")); + + Some(StrippedHeader { + status, + note, + rest: rest.to_string(), + }) +} + +fn split_first_line(text: &str) -> (&str, &str) { + match text.find('\n') { + Some(idx) => (&text[..idx], &text[idx + 1..]), + None => (text, ""), + } +} + +fn drop_line_if(text: &str, pred: impl Fn(&str) -> bool) -> &str { + let (line, after) = split_first_line(text); + if pred(line.trim()) { + after + } else { + text + } +} + +// ── background shell sessions ──────────────────────────────────────────── + +/// Phrases codex uses to announce a background shell session's id. Matched +/// case-insensitively, anchored on the `with ` AND on the trailing space, so a +/// command whose *output* merely mentions a session id cannot mint a bogus +/// mapping. Both anchors earn their keep against real transcripts: the +/// commentary line `…established with agent-assigned session ID` fails the +/// first, and a markdown heading `### Streamable HTTP with Session ID` (whose +/// next line starts with a line number) fails the second. +const SESSION_ID_PHRASES: [&str; 2] = ["with session id ", "with cell id "]; + +/// The same id inside the JSON result a code-mode `tools.exec_command(…)` +/// returns. Value may be a number or a quoted string. +const SESSION_ID_JSON_KEY: &str = "\"session_id\""; + +/// codex stamps every exec result with a chunk id, in the prose envelope +/// (`Chunk ID: 98f2d1`) as well as the code-mode JSON one +/// (`{"chunk_id":"a74f94","wall_time_seconds":…,"session_id":7106,…}`). +/// +/// The `"session_id"` JSON form is only honored inside such an envelope: +/// printing that key is an extremely ordinary thing for a command to do +/// (`grep -rn session_id src/` hits it thousands of times in this very repo), +/// and a bogus mapping would put the wrong command in a `wait` card's title. +/// The two prose phrases need no such gate — they are codex's own wording. +const CHUNK_ID_MARKERS: [&str; 2] = ["chunk id:", "\"chunk_id\""]; + +/// Background shell session ids announced by one tool output — the ids a later +/// `wait` (`cell_id`) or `write_stdin` (`session_id`) call addresses. +/// +/// codex publishes them in three shapes, all present in real rollouts: +/// +/// ```text +/// Chunk ID: 523e44 … Process running with session ID 22068 (string envelope) +/// Script running with cell ID 56 (code-mode header) +/// {"chunk_id":"a74f94","session_id":7106,"output":""} (code-mode result) +/// ``` +/// +/// Returned in announcement order with duplicates collapsed. +pub fn extract_shell_session_ids(text: &str) -> Vec { + let lower = text.to_ascii_lowercase(); + let mut hits: Vec<(usize, String)> = Vec::new(); + + for phrase in SESSION_ID_PHRASES { + let mut from = 0; + while let Some(offset) = lower[from..].find(phrase) { + let at = from + offset + phrase.len(); + if let Some(id) = leading_digits(&lower[at..]) { + hits.push((at, id)); + } + from = at; + } + } + + if CHUNK_ID_MARKERS.iter().any(|m| lower.contains(m)) { + let mut from = 0; + while let Some(offset) = lower[from..].find(SESSION_ID_JSON_KEY) { + let at = from + offset + SESSION_ID_JSON_KEY.len(); + let value = lower[at..].trim_start_matches([' ', ':', '"']); + if let Some(id) = leading_digits(value) { + hits.push((at, id)); + } + from = at; + } + } + + hits.sort_by_key(|(at, _)| *at); + let mut ids: Vec = Vec::with_capacity(hits.len()); + for (_, id) in hits { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + +/// The chunk ids stamped on one tool output. codex lets a `wait` address a cell +/// by the hex chunk id of the envelope that announced it just as well as by the +/// numeric session id inside — `{"cell_id":"8716ba"}` and `{"cell_id":"13185"}` +/// name the same cell when the envelope reads +/// `{"chunk_id":"8716ba",…,"session_id":13185,…}`. +/// +/// Only meaningful next to a session announcement, which is the caller's to +/// check: every exec result carries a chunk id, and one from a command that has +/// already exited names nothing to poll. +pub fn extract_chunk_ids(text: &str) -> Vec { + let lower = text.to_ascii_lowercase(); + let mut ids: Vec = Vec::new(); + for marker in CHUNK_ID_MARKERS { + let mut from = 0; + while let Some(offset) = lower[from..].find(marker) { + let at = from + offset + marker.len(); + let value = lower[at..].trim_start_matches([' ', ':', '"']); + let id: String = value + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + if !id.is_empty() && !ids.contains(&id) { + ids.push(id); + } + from = at; + } + } + ids +} + +fn leading_digits(text: &str) -> Option { + let digits: String = text.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() { + None + } else { + Some(digits) + } +} + +// ── JS literal scanning ────────────────────────────────────────────────── + +fn is_ident_byte(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'$' +} + +fn is_ident_start(c: u8) -> bool { + c.is_ascii_alphabetic() || c == b'_' || c == b'$' +} + +fn preceded_by_ident(b: &[u8], i: usize) -> bool { + i > 0 && (is_ident_byte(b[i - 1]) || b[i - 1] == b'.') +} + +fn skip_ws_and_comments(b: &[u8], mut i: usize) -> usize { + loop { + while i < b.len() && b[i].is_ascii_whitespace() { + i += 1; + } + if b.get(i) == Some(&b'/') && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + continue; + } + if b.get(i) == Some(&b'/') && b.get(i + 1) == Some(&b'*') { + i += 2; + while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(b.len()); + continue; + } + return i; + } +} + +/// `i` points at the opening quote. Returns the index just past the closing +/// quote (or `b.len()` when unterminated). Template `${…}` holes are skipped +/// as balanced blocks so a `}` inside them cannot end an enclosing literal. +fn scan_string(b: &[u8], i: usize) -> usize { + let quote = b[i]; + let mut i = i + 1; + while i < b.len() { + let c = b[i]; + if c == b'\\' { + i += 2; + continue; + } + if c == quote { + return i + 1; + } + if quote == b'`' && c == b'$' && b.get(i + 1) == Some(&b'{') { + i = scan_balanced(b, i + 1).unwrap_or(b.len()); + continue; + } + i += 1; + } + b.len() +} + +/// `i` points at `{` or `[`. Returns the index just past the matching close. +fn scan_balanced(b: &[u8], i: usize) -> Option { + let mut depth = 0usize; + let mut i = i; + while i < b.len() { + match b[i] { + b'"' | b'\'' | b'`' => { + i = scan_string(b, i); + continue; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_ws_and_comments(b, i); + continue; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i + 1); + } + } + _ => {} + } + i += 1; + } + None +} + +/// Parse a JS literal (object / array / string / number / keyword) into JSON. +/// Returns `None` for anything that needs evaluation: identifiers, shorthand +/// properties, spreads, template interpolation, calls. +pub fn js_literal_to_json(src: &str) -> Option { + let mut parser = LiteralParser::new(src); + let value = parser.value()?; + parser.skip(); + if parser.i != parser.b.len() { + return None; + } + Some(value) +} + +struct LiteralParser<'a> { + src: &'a str, + b: &'a [u8], + i: usize, +} + +impl<'a> LiteralParser<'a> { + fn new(src: &'a str) -> Self { + Self { + src, + b: src.as_bytes(), + i: 0, + } + } + + fn skip(&mut self) { + self.i = skip_ws_and_comments(self.b, self.i); + } + + fn peek(&self) -> Option { + self.b.get(self.i).copied() + } + + fn value(&mut self) -> Option { + self.skip(); + match self.peek()? { + b'{' => self.object(), + b'[' => self.array(), + q @ (b'"' | b'\'' | b'`') => self.string(q).map(Value::String), + c if is_ident_start(c) => self.keyword(), + _ => self.number(), + } + } + + fn object(&mut self) -> Option { + self.i += 1; // consume '{' + let mut map = Map::new(); + loop { + self.skip(); + match self.peek()? { + b'}' => { + self.i += 1; + return Some(Value::Object(map)); + } + b',' => { + self.i += 1; + continue; + } + // spread (`...rest`) needs evaluation + b'.' => return None, + _ => {} + } + let key = self.key()?; + self.skip(); + // A missing `:` means shorthand (`{cmd, workdir}`) or a method — + // both carry values only the runtime knows. + if self.peek()? != b':' { + return None; + } + self.i += 1; + let value = self.value()?; + map.insert(key, value); + } + } + + fn array(&mut self) -> Option { + self.i += 1; // consume '[' + let mut items = Vec::new(); + loop { + self.skip(); + match self.peek()? { + b']' => { + self.i += 1; + return Some(Value::Array(items)); + } + b',' => { + self.i += 1; + continue; + } + b'.' => return None, + _ => {} + } + items.push(self.value()?); + } + } + + fn key(&mut self) -> Option { + self.skip(); + match self.peek()? { + q @ (b'"' | b'\'' | b'`') => self.string(q), + c if is_ident_start(c) || c.is_ascii_digit() => { + let start = self.i; + while self.i < self.b.len() && is_ident_byte(self.b[self.i]) { + self.i += 1; + } + Some(self.src.get(start..self.i)?.to_string()) + } + // A computed key (`[expr]: …`) is not statically known. + _ => None, + } + } + + fn string(&mut self, quote: u8) -> Option { + let end = scan_string(self.b, self.i); + let raw = self.src.get(self.i..end)?; + self.i = end; + decode_js_string(raw, quote) + } + + fn keyword(&mut self) -> Option { + let start = self.i; + while self.i < self.b.len() && is_ident_byte(self.b[self.i]) { + self.i += 1; + } + match self.src.get(start..self.i)? { + "true" => Some(Value::Bool(true)), + "false" => Some(Value::Bool(false)), + "null" | "undefined" => Some(Value::Null), + // Any other bare identifier is a variable reference. + _ => None, + } + } + + fn number(&mut self) -> Option { + let start = self.i; + if self.peek() == Some(b'-') { + self.i += 1; + } + let digits_start = self.i; + while self + .peek() + .is_some_and(|c| c.is_ascii_digit() || c == b'.') + { + self.i += 1; + } + if self.i == digits_start { + return None; + } + if matches!(self.peek(), Some(b'e') | Some(b'E')) { + self.i += 1; + if matches!(self.peek(), Some(b'+') | Some(b'-')) { + self.i += 1; + } + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.i += 1; + } + } + // `10n`, `0x1f`, `1_000` — not plain JSON numbers. + if self.peek().is_some_and(is_ident_byte) { + return None; + } + serde_json::from_str::(self.src.get(start..self.i)?).ok() + } +} + +/// Decode a JS string literal (quotes included) into its runtime value. +/// Template literals carrying a `${…}` hole are rejected — their value is only +/// known at runtime. +fn decode_js_string(raw: &str, quote: u8) -> Option { + let quote = quote as char; + let inner = raw.strip_prefix(quote)?.strip_suffix(quote)?; + let chars: Vec = inner.chars().collect(); + let mut out = String::with_capacity(inner.len()); + let mut i = 0usize; + + while i < chars.len() { + let c = chars[i]; + if c != '\\' { + if quote == '`' && c == '$' && chars.get(i + 1) == Some(&'{') { + return None; + } + out.push(c); + i += 1; + continue; + } + i += 1; + let escape = *chars.get(i)?; + i += 1; + match escape { + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'r' => out.push('\r'), + 'b' => out.push('\u{8}'), + 'f' => out.push('\u{c}'), + 'v' => out.push('\u{b}'), + '0' if !chars.get(i).is_some_and(|c| c.is_ascii_digit()) => out.push('\0'), + '\n' => {} + 'x' => { + let value = hex_value(&chars, i, 2)?; + i += 2; + out.push(char::from_u32(value).unwrap_or('\u{fffd}')); + } + 'u' => { + if chars.get(i) == Some(&'{') { + let close = chars[i..].iter().position(|c| *c == '}')? + i; + let text: String = chars[i + 1..close].iter().collect(); + let value = u32::from_str_radix(&text, 16).ok()?; + i = close + 1; + out.push(char::from_u32(value).unwrap_or('\u{fffd}')); + } else { + let value = hex_value(&chars, i, 4)?; + i += 4; + // Recombine a surrogate pair written as two \uXXXX escapes. + if (0xd800..0xdc00).contains(&value) + && chars.get(i) == Some(&'\\') + && chars.get(i + 1) == Some(&'u') + { + if let Some(low) = hex_value(&chars, i + 2, 4) { + if (0xdc00..0xe000).contains(&low) { + i += 6; + let combined = + 0x10000 + ((value - 0xd800) << 10) + (low - 0xdc00); + out.push(char::from_u32(combined).unwrap_or('\u{fffd}')); + continue; + } + } + } + out.push(char::from_u32(value).unwrap_or('\u{fffd}')); + } + } + other => out.push(other), + } + } + + Some(out) +} + +fn hex_value(chars: &[char], at: usize, len: usize) -> Option { + let slice = chars.get(at..at + len)?; + let text: String = slice.iter().collect(); + u32::from_str_radix(&text, 16).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn calls(src: &str) -> Vec { + parse_code_mode_script(src) + .calls + .unwrap_or_else(|| panic!("script did not resolve: {src}")) + } + + #[test] + fn single_exec_command_resolves_to_bare_command() { + let src = "const r = await tools.exec_command({\n cmd: \"git status --short\",\n workdir: \"/repo\",\n yield_time_ms: 10000\n});\ntext(r.output);\n"; + let got = calls(src); + assert_eq!(got.len(), 1); + assert_eq!(got[0].tool_name, "exec_command"); + assert_eq!(got[0].input_preview, "git status --short"); + } + + #[test] + fn quoted_keys_and_escapes_decode() { + let src = r#"const r=await tools.exec_command({"cmd":"sed -n '1,20p' \"a b.txt\"\nwc -l","workdir":"/repo"}); text(r.output)"#; + let got = calls(src); + assert_eq!(got[0].input_preview, "sed -n '1,20p' \"a b.txt\"\nwc -l"); + } + + #[test] + fn braces_inside_strings_do_not_end_the_literal() { + let src = r#"const r = await tools.exec_command({cmd: "awk '{print $1}' f | sed 's/}/]/'", workdir: "/repo"}); text(r.output)"#; + let got = calls(src); + assert_eq!(got[0].input_preview, "awk '{print $1}' f | sed 's/}/]/'"); + } + + #[test] + fn comments_and_trailing_commas_are_tolerated() { + let src = "const r = await tools.exec_command({\n // read it\n cmd: 'ls -la', /* inline */\n workdir: '/repo',\n});"; + let got = calls(src); + assert_eq!(got[0].input_preview, "ls -la"); + } + + #[test] + fn parallel_calls_keep_source_order() { + let src = "const rs = await Promise.all([\n tools.exec_command({cmd:\"one\"}),\n tools.exec_command({cmd:\"two\"}),\n tools.exec_command({cmd:\"three\"})\n]);\nrs.forEach(r => text(r.output));"; + let got = calls(src); + assert_eq!( + got.iter().map(|c| c.input_preview.as_str()).collect::>(), + vec!["one", "two", "three"] + ); + } + + #[test] + fn apply_patch_resolves_through_a_declaration() { + let src = "const patch = \"*** Begin Patch\\n*** Update File: a.rs\\n+x\\n*** End Patch\";\nconst result = await tools.apply_patch(patch);\ntext(result);\n"; + let got = calls(src); + assert_eq!(got.len(), 1); + assert_eq!(got[0].tool_name, "apply_patch"); + assert_eq!( + got[0].input_preview, + "*** Begin Patch\n*** Update File: a.rs\n+x\n*** End Patch" + ); + } + + #[test] + fn mcp_and_plan_calls_keep_their_json_arguments() { + let src = "const plan = await tools.update_plan({plan:[{step:\"a\",status:\"pending\"}]});\nconst r = await tools.mcp__codeg_mcp__get_delegation_status({task_ids:[\"t1\"],wait_ms:60000});\ntext(JSON.stringify(r));"; + let got = calls(src); + assert_eq!(got[0].tool_name, "update_plan"); + assert_eq!( + got[0].input_preview, + r#"{"plan":[{"status":"pending","step":"a"}]}"# + ); + assert_eq!(got[1].tool_name, "mcp__codeg_mcp__get_delegation_status"); + assert_eq!( + got[1].input_preview, + r#"{"task_ids":["t1"],"wait_ms":60000}"# + ); + } + + const TWO_CALLS: &str = "const r = await Promise.all([tools.exec_command({cmd:\"a\"}), tools.exec_command({cmd:\"b\"})]);\n"; + + #[test] + fn one_loop_printing_twice_groups_two_chunks_per_call() { + let src = format!( + "{TWO_CALLS}for (const x of r) {{ text(x.output); text(`exit_code=${{x.exit_code}}`); }}\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, Some(2)); + } + + #[test] + fn a_foreach_body_counts_the_same_way() { + let src = format!("{TWO_CALLS}r.forEach((x, i) => {{ text(`---RESULT ${{i}}---`); text(x.output); }});\n"); + assert_eq!(parse_code_mode_script(&src).text_run, Some(2)); + } + + #[test] + fn an_index_loop_over_the_results_counts_the_same_way() { + let src = format!("{TWO_CALLS}for (let i = 0; i < r.length; i++) {{ text(`---RESULT ${{i}}---`); text(r[i].output); }}\n"); + assert_eq!(parse_code_mode_script(&src).text_run, Some(2)); + } + + /// Same two `text()` calls, same chunk count — but two passes, so the chunks + /// arrive output-output-exit-exit and cannot be grouped per call. + #[test] + fn two_passes_over_the_results_do_not_group() { + let src = format!( + "{TWO_CALLS}for (const x of r) text(x.output);\nfor (const x of r) text(`exit_code=${{x.exit_code}}`);\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// The same two passes with the calls hoisted into helpers: the `text()` + /// calls now sit side by side at the top of the script, so only the loop + /// body — not their neighbourhood — tells the shapes apart. + #[test] + fn text_calls_hoisted_out_of_the_loop_do_not_group() { + let src = format!( + "{TWO_CALLS}const emitOutput = x => text(x.output);\nconst emitExit = x => text(`exit_code=${{x.exit_code}}`);\nfor (const x of r) emitOutput(x);\nfor (const x of r) emitExit(x);\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// The loop header names the binding, but the array it walks was turned + /// around in place before it got there — so group *i* is call *n - 1 - i* + /// even though every other check passes. + #[test] + fn a_results_array_reordered_before_the_loop_does_not_group() { + for mutation in ["r.reverse();", "r.sort();", "r.splice(0, 1);", "r = other;"] { + let src = format!( + "{TWO_CALLS}{mutation}\nfor (const x of r) {{ text(x.output); text(`exit_code=${{x.exit_code}}`); }}\n" + ); + assert_eq!( + parse_code_mode_script(&src).text_run, + None, + "{mutation} must not be treated as a plain walk" + ); + } + } + + /// `++` is an update, not two `+` in a row — and it can move the counter + /// out from under the very read that names the call. + #[test] + fn a_prefix_update_inside_an_argument_does_not_group() { + let src = format!( + "{TWO_CALLS}for (let i = 0; i < r.length; i++) {{ text(\"slot\"); text(i ? ++i : r[i].output); }}\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// `forEach` takes a second argument, and it runs before the first + /// iteration. + #[test] + fn a_foreach_second_argument_does_not_group() { + let src = format!( + "{TWO_CALLS}r.forEach(x => {{ text(\"slot\"); text(x.output); }}, r.reverse());\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// The calls are recovered in source order, so the array `Promise.all` + /// receives has to be the one written at the call sites — reordered from + /// outside, or from within by a spread. + #[test] + fn a_reordered_promise_all_argument_does_not_group() { + let walk = "\nfor (const x of r) { text(\"slot\"); text(x.output); }\n"; + for arg in [ + "[tools.exec_command({cmd:\"a\"}), tools.exec_command({cmd:\"b\"})].reverse()", + "[tools.exec_command({cmd:\"a\"}), ...[tools.exec_command({cmd:\"b\"}), tools.exec_command({cmd:\"c\"})].reverse()]", + "[tools.exec_command({cmd:\"a\"})].concat([tools.exec_command({cmd:\"b\"})])", + ] { + let src = format!("const r = await Promise.all({arg});{walk}"); + assert_eq!(parse_code_mode_script(&src).text_run, None, "{arg}"); + } + } + + /// `` tag`…` `` calls `tag`. Reading it as a string would let the body run + /// code the grammar never sees. + #[test] + fn a_tagged_template_is_not_a_string() { + let src = format!( + "{TWO_CALLS}for (const x of r) {{ text(\"slot\"); text(JSON.stringify.constructor`return 1`); }}\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + + // The same trick at statement position, where `text` is the only + // identifier that parses and it must be followed by its `(`. + let split = format!("{TWO_CALLS}for (const x of r) {{ text;\n(JSON.stringify.constructor`return 1`); text(x.output); }}\n"); + assert_eq!(parse_code_mode_script(&split).text_run, None); + } + + /// A literal that never closes means the rest cannot be read with any + /// confidence — say nothing, and do not panic saying it. + #[test] + fn malformed_literals_are_refused_without_panicking() { + for tail in [ + "for (const x of r) { text(\"unterminated); text(x.output); }", + "for (const x of r) { text(`unterminated ${x.output}); }", + "for (const x of r) { text(x.output); /* unterminated", + "for (const x of r) { text(`${`nested", + ] { + let src = format!("{TWO_CALLS}{tail}"); + assert_eq!(parse_code_mode_script(&src).text_run, None, "{tail}"); + } + } + + /// `${…}` is code, not text. A mutation hidden in one is still a mutation. + #[test] + fn a_mutation_inside_a_template_hole_does_not_group() { + let src = format!( + "{TWO_CALLS}`${{r.reverse()}}`;\nfor (const x of r) {{ text(x.output); text(\"done\"); }}\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// `forEach` hands its callback the array itself as a third argument, which + /// is an alias no lexical check on `r` can see. + #[test] + fn a_foreach_callback_taking_the_array_does_not_group() { + let src = format!( + "{TWO_CALLS}r.forEach((x, i, all) => {{ if (!i) all.reverse(); text(all[i].output); text(\"done\"); }});\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// Every `text()` is inside the one loop body, but they run from a callback + /// scheduled per iteration, so what they print need not arrive in the order + /// the loop ran. + #[test] + fn deferred_work_inside_the_loop_does_not_group() { + let src = format!( + "{TWO_CALLS}for (const x of r) {{ setTimeout(() => {{ text(x.output); text(\"done\"); }}, 0); }}\n" + ); + assert_eq!(parse_code_mode_script(&src).text_run, None); + } + + /// The counter is what makes the index form ordered. Writing to it — or to + /// the slot it reads — takes that away. + #[test] + fn writing_to_the_counter_or_its_slot_does_not_group() { + for body in [ + "const j = i; i = 1 - i; text(r[i].output); i = j;", + "r[i] = r[0]; text(r[i].output); text(\"done\");", + ] { + let src = format!("{TWO_CALLS}for (let i = 0; i < r.length; i++) {{ {body} }}\n"); + assert_eq!( + parse_code_mode_script(&src).text_run, + None, + "must not group: {body}" + ); + } + } + + /// One loop, contiguous groups — but walking the results backwards, so + /// group *i* is call *n - 1 - i*. + #[test] + fn a_reversed_walk_does_not_group() { + let reversed = format!( + "{TWO_CALLS}for (const x of r.toReversed()) {{ text(x.output); text(`exit_code=${{x.exit_code}}`); }}\n" + ); + assert_eq!(parse_code_mode_script(&reversed).text_run, None); + + let by_index = format!("{TWO_CALLS}for (let i = 0; i < r.length; i++) {{ text(`---${{i}}---`); text(r[r.length - 1 - i].output); }}\n"); + assert_eq!(parse_code_mode_script(&by_index).text_run, None); + } + + /// `//text()` is an XPath inside a command string, not a call site — 11 of + /// the sampled candidate scripts pass exactly this to `xmllint`. + #[test] + fn text_inside_a_command_string_is_not_a_call_site() { + let src = format!("const r = await Promise.all([tools.exec_command({{cmd: \"xmllint --html --xpath '//div//text()' -\"}}), tools.exec_command({{cmd:\"b\"}})]);\nfor (const x of r) {{ text(x.output); text(\"done\"); }}\n"); + assert_eq!(parse_code_mode_script(&src).text_run, Some(2)); + } + + #[test] + fn a_script_that_prints_nothing_has_no_run() { + let src = "await tools.exec_command({cmd: \"a\"});"; + assert_eq!(parse_code_mode_script(src).text_run, None); + } + + #[test] + fn tools_reference_inside_a_string_is_not_a_call_site() { + let src = "const r = await tools.exec_command({cmd: \"echo 'tools.exec_command({cmd:1})'\"}); text(r.output)"; + let got = calls(src); + assert_eq!(got.len(), 1); + assert_eq!(got[0].input_preview, "echo 'tools.exec_command({cmd:1})'"); + } + + #[test] + fn shorthand_properties_do_not_resolve() { + let src = "const results = await Promise.all(cmds.map(({cmd, workdir}) => tools.exec_command({cmd, workdir, yield_time_ms: 10000})));"; + let parsed = parse_code_mode_script(src); + assert!(parsed.calls.is_none()); + assert_eq!(parsed.call_sites, 1); + } + + #[test] + fn identifier_argument_without_declaration_does_not_resolve() { + let src = "const results = await Promise.all(cmds.map(c => tools.exec_command(c)));"; + assert!(parse_code_mode_script(src).calls.is_none()); + } + + #[test] + fn template_interpolation_does_not_resolve() { + let src = "const r = await tools.exec_command({cmd: `sed -n '1,${n}p' f`}); text(r.output)"; + assert!(parse_code_mode_script(src).calls.is_none()); + } + + #[test] + fn script_without_tool_calls_does_not_resolve() { + let parsed = parse_code_mode_script("text('hello');\n"); + assert!(parsed.calls.is_none()); + assert_eq!(parsed.call_sites, 0); + assert_eq!(parsed.summary, None); + } + + #[test] + fn summary_falls_back_to_a_literal_command_when_calls_do_not_resolve() { + let src = "const cmds = [\n {cmd: \"cargo test\", workdir: \"/repo\"},\n {cmd: \"cargo clippy\", workdir: \"/repo\"}\n];\nconst rs = await Promise.all(cmds.map(c => tools.exec_command(c)));"; + let parsed = parse_code_mode_script(src); + assert!(parsed.calls.is_none()); + assert_eq!(parsed.summary.as_deref(), Some("cargo test")); + } + + #[test] + fn summary_uses_the_first_command_line() { + let src = "const r = await tools.exec_command({cmd: \"cd /repo\\nmake build\"});"; + assert_eq!( + parse_code_mode_script(src).summary.as_deref(), + Some("cd /repo") + ); + } + + #[test] + fn completed_envelope_is_stripped_per_chunk() { + let output = serde_json::json!([ + {"type": "input_text", "text": "Script completed\nWall time 0.5 seconds\nOutput:\n"}, + {"type": "input_text", "text": "first"}, + {"type": "input_text", "text": "second"}, + ]); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.status, ScriptStatus::Completed); + assert_eq!(parsed.chunks, vec!["first", "second"]); + assert_eq!(parsed.note, None); + assert!(!parsed.is_error()); + } + + #[test] + fn failed_envelope_marks_the_result_as_an_error() { + let output = serde_json::json!([ + {"type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "Script error:\nexec_command failed for `ls`"}, + ]); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.status, ScriptStatus::Failed); + assert!(parsed.is_error()); + assert!(parsed.joined().starts_with("Script error:")); + } + + #[test] + fn running_envelope_keeps_the_cell_note() { + let output = serde_json::json!([ + {"type": "input_text", "text": "Script running with cell ID 56\nWall time 11.0 seconds\nOutput:\n"}, + {"type": "input_text", "text": "building…"}, + ]); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.status, ScriptStatus::Running); + assert_eq!(parsed.note.as_deref(), Some("Script running with cell ID 56")); + assert_eq!(parsed.chunks, vec!["building…"]); + } + + #[test] + fn aborted_envelope_is_an_error_with_no_output() { + let output = serde_json::json!("aborted by user after 3s"); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.status, ScriptStatus::Aborted); + assert!(parsed.is_error()); + assert!(parsed.chunks.is_empty()); + assert_eq!(parsed.note.as_deref(), Some("aborted by user after 3s")); + } + + #[test] + fn plain_string_output_is_a_single_chunk() { + let output = serde_json::json!("Script completed\nWall time 0.1 seconds\nOutput:\nhello"); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.chunks, vec!["hello"]); + } + + #[test] + fn output_without_an_envelope_is_left_alone() { + let output = serde_json::json!("just text"); + let parsed = split_code_mode_output(Some(&output)); + assert_eq!(parsed.status, ScriptStatus::Unknown); + assert_eq!(parsed.chunks, vec!["just text"]); + } + + /// One call SITE is not one invocation, and arguments reached through a + /// variable are only that variable's FIRST value — `job.cmd = cmd` inside a + /// loop runs commands this scanner never sees. `args_inline` is what + /// separates a recovered command that provably ran from one that is a + /// guess; only the former may be credited with a background session. + #[test] + fn arguments_reached_through_a_variable_are_not_proof_of_what_ran() { + let mutated = parse_code_mode_script( + "const job = {cmd: \"pnpm build\"};\nfor (const cmd of [\"pnpm build\", \"pnpm dev\"]) {\n job.cmd = cmd;\n const r = await tools.exec_command(job);\n text(JSON.stringify(r));\n}\n", + ); + let calls = mutated.calls.expect("the identifier resolves"); + assert_eq!(calls[0].input_preview, "pnpm build"); + assert!( + !calls[0].args_inline, + "resolved through `job`, whose later value this scanner cannot see" + ); + + let inline = parse_code_mode_script( + "const r = await tools.exec_command({cmd: \"pnpm dev\"});\ntext(r.output);\n", + ); + let calls = inline.calls.expect("literal arguments resolve"); + assert!(calls[0].args_inline); + + // A literal that is only PART of the argument expression is not the + // argument: `job` is what runs. + let compound = parse_code_mode_script( + "for (const job of jobs) {\n const r = await tools.exec_command({cmd: \"pnpm build\"} && job);\n text(r.output);\n}\n", + ); + assert_eq!(compound.calls, None); + } + + #[test] + fn a_multi_call_script_that_does_not_resolve_recovers_no_calls() { + let script = parse_code_mode_script( + "await tools.exec_command({cmd: \"pnpm dev\", ...opts});\nawait tools.exec_command({cmd: \"cargo watch\", ...opts});\n", + ); + assert_eq!(script.calls, None); + assert_eq!(script.call_sites, 2); + } + + #[test] + fn session_ids_are_read_from_all_three_announcement_shapes() { + assert_eq!( + extract_shell_session_ids( + "Chunk ID: 523e44\nWall time: 30.0 seconds\nProcess running with session ID 22068\nOutput:\n" + ), + vec!["22068"] + ); + assert_eq!( + extract_shell_session_ids("Script running with cell ID 56"), + vec!["56"] + ); + assert_eq!( + extract_shell_session_ids( + r#"{"chunk_id":"a74f94","wall_time_seconds":1.0,"session_id":7106,"output":""}"# + ), + vec!["7106"] + ); + // Quoted value, same envelope. + assert_eq!( + extract_shell_session_ids(r#"{"chunk_id":"a7","session_id":"8"}"#), + vec!["8"] + ); + } + + /// A `wait` may address the cell by the envelope's hex chunk id instead of + /// the numeric session id inside it. Both spellings appear in real + /// transcripts for the same cell. + #[test] + fn chunk_ids_are_read_from_both_envelope_forms() { + assert_eq!( + extract_chunk_ids( + "Chunk ID: 523e44\nWall time: 30.0 seconds\nProcess running with session ID 22068\nOutput:\n" + ), + vec!["523e44"] + ); + assert_eq!( + extract_chunk_ids( + r#"{"chunk_id":"8716ba","wall_time_seconds":1.0,"session_id":13185,"output":""}"# + ), + vec!["8716ba"] + ); + assert!(extract_chunk_ids("no envelope here").is_empty()); + } + + #[test] + fn several_announcements_keep_order_and_collapse_duplicates() { + let text = "Process running with session ID 10\nScript running with cell ID 11\nProcess running with session ID 10"; + assert_eq!(extract_shell_session_ids(text), vec!["10", "11"]); + } + + #[test] + fn text_that_merely_mentions_a_session_id_announces_nothing() { + // Every one of these is a real shape seen in command output — a wrong + // mapping would put someone else's command in a `wait` card's title. + for text in [ + // Prose about sessions, not codex's own wording. + "Connection established with agent-assigned session ID\n 4242", + // A markdown heading followed by a numbered line. + "### Streamable HTTP with Session ID\n 17", + // Source code printed by a grep — the JSON key with no envelope. + "src/x.rs:12: json!({ \"session_id\": 1 })", + "sessionIdRef.current = connSessionId\n 99", + ] { + assert!( + extract_shell_session_ids(text).is_empty(), + "false positive on {text:?}" + ); + } + } + + #[test] + fn note_is_appended_to_the_output_blob() { + assert_eq!( + with_note(Some("out".to_string()), Some("still running")).as_deref(), + Some("out\n\nstill running") + ); + assert_eq!( + with_note(Some(" ".to_string()), Some("still running")).as_deref(), + Some("still running") + ); + assert_eq!(with_note(None, None), None); + } + + #[test] + fn unicode_escapes_decode_including_surrogate_pairs() { + let src = r#"const r = await tools.exec_command({cmd: "echo 你好 🚀"});"#; + assert_eq!(calls(src)[0].input_preview, "echo 你好 🚀"); + } + + #[test] + fn script_card_input_carries_source_title_and_count() { + let json: Value = + serde_json::from_str(&script_card_input("const a = 1", Some("ls"), 3)).unwrap(); + assert_eq!(json["source"], "const a = 1"); + assert_eq!(json["title"], "ls"); + assert_eq!(json["call_count"], 3); + } +} diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 2beaf192cc..68b182d076 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -3,6 +3,7 @@ pub mod claude; pub mod cline; pub mod codebuddy; pub mod codex; +pub mod codex_code_mode; pub mod cursor; pub mod gemini; pub mod grok; diff --git a/src/components/ai-elements/json-tree.test.tsx b/src/components/ai-elements/json-tree.test.tsx new file mode 100644 index 0000000000..c56a6085fe --- /dev/null +++ b/src/components/ai-elements/json-tree.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +/** + * The last-resort renderer for tool payloads with no dedicated card. Before it, + * both fallback paths (`ToolOutput` results and `GenericToolInput` nested + * values) pretty-printed the JSON into a code block — unreadable for exactly + * the deeply-nested MCP results that lack a card in the first place. + */ + +vi.mock("@/components/ai-elements/code-block", () => ({ + CodeBlock: ({ code, language }: { code: string; language?: string }) => ( +
+      {code}
+    
+ ), +})) + +import { JsonTreeView, countJsonNodes, parseJsonForTree } from "./json-tree" +import enMessages from "@/i18n/messages/en.json" + +function renderTree(value: unknown, rawText?: string) { + return render( + + + + ) +} + +describe("parseJsonForTree", () => { + it("accepts objects and arrays", () => { + expect(parseJsonForTree('{"a":1}')).toEqual({ a: 1 }) + expect(parseJsonForTree(" [1, 2]")).toEqual([1, 2]) + }) + + it("rejects scalars and non-JSON — they read better as themselves", () => { + expect(parseJsonForTree('"just text"')).toBeUndefined() + expect(parseJsonForTree("42")).toBeUndefined() + expect(parseJsonForTree("not json at all")).toBeUndefined() + expect(parseJsonForTree('{"a":')).toBeUndefined() + }) +}) + +describe("countJsonNodes", () => { + it("stops counting once past the cap instead of walking the whole value", () => { + const wide = { items: Array.from({ length: 5000 }, (_, i) => i) } + expect(countJsonNodes(wide, 10)).toBeLessThan(20) + expect(countJsonNodes(wide, 10)).toBeGreaterThan(10) + }) +}) + +describe("JsonTreeView", () => { + it("expands a small payload and reveals its leaves", () => { + renderTree({ status: "completed", agent: { name: "codex", runs: 3 } }) + + expect(screen.getByText("completed", { exact: false })).toBeInTheDocument() + expect(screen.getByText("codex", { exact: false })).toBeInTheDocument() + expect(screen.getByText("3")).toBeInTheDocument() + }) + + it("collapses containers past the auto-expand budget, and opens them on click", () => { + const value = { + tasks: Array.from({ length: 80 }, (_, i) => ({ + id: `task-${i}`, + done: false, + })), + } + renderTree(value) + + // The whole list would blow the row budget, so it starts collapsed… + expect(screen.queryByText("task-0", { exact: false })).toBeNull() + fireEvent.click(screen.getByText("80 items", { exact: false })) + + // …one click shows its 80 (still collapsed) entries… + const entries = screen.getAllByText("2 fields", { exact: false }) + expect(entries).toHaveLength(80) + + // …and opening one reaches the leaf. + fireEvent.click(entries[0]) + expect(screen.getByText("task-0", { exact: false })).toBeInTheDocument() + }) + + it("truncates a long string until it is clicked", () => { + const long = "x".repeat(400) + renderTree({ result: long }) + + const preview = screen.getByRole("button", { name: /x{50}/ }) + expect(preview.textContent).not.toBe(long) + fireEvent.click(preview) + expect(screen.getByRole("button", { name: long })).toBeInTheDocument() + }) + + it("falls back to the code block for oversized payloads", () => { + const huge = { rows: Array.from({ length: 3000 }, (_, i) => ({ i })) } + renderTree(huge) + + expect(screen.getByTestId("code-block")).toHaveAttribute( + "data-language", + "json" + ) + }) + + it("toggles between the tree and the original raw text", () => { + const raw = '{"b":1,"a":2}' + renderTree(JSON.parse(raw), raw) + + expect(screen.queryByTestId("code-block")).toBeNull() + fireEvent.click(screen.getByRole("button", { name: "Show raw JSON" })) + // The ORIGINAL text, key order intact — not a re-serialization. + expect(screen.getByTestId("code-block").textContent).toBe(raw) + fireEvent.click(screen.getByRole("button", { name: "Show tree" })) + expect(screen.queryByTestId("code-block")).toBeNull() + }) +}) diff --git a/src/components/ai-elements/json-tree.tsx b/src/components/ai-elements/json-tree.tsx new file mode 100644 index 0000000000..821cdb96d0 --- /dev/null +++ b/src/components/ai-elements/json-tree.tsx @@ -0,0 +1,313 @@ +"use client" + +/** + * Collapsible JSON viewer — the last-resort renderer for tool payloads that + * have no dedicated card. + * + * Both fallback paths used to hand the value to ``: + * `ToolOutput` for results and `GenericToolInput` for nested parameter values. + * Pretty-printed JSON is fine for a flat `{status, id}`, but an MCP result with + * a few levels of nesting and a multi-KB string inside becomes an unreadable + * wall — which is exactly what a tool WITHOUT a card tends to return. + * + * Deliberately generic: nothing here knows about any particular agent or tool. + */ + +import { memo, useCallback, useMemo, useState } from "react" +import { BracesIcon, ChevronRightIcon, ListTreeIcon } from "lucide-react" +import { useTranslations } from "next-intl" + +import { CodeBlock } from "@/components/ai-elements/code-block" +import { cn } from "@/lib/utils" + +/** + * Above this many nodes the tree stops paying for itself: React would mount + * thousands of rows for a payload nobody reads top-to-bottom. Such values fall + * back to the plain code block. + */ +const MAX_NODES = 2000 + +/** + * Containers are expanded breadth-first until the tree would show more than + * this many rows, so a small payload is fully visible at a glance while a large + * one opens only its outline. + */ +const AUTO_EXPAND_ROWS = 50 + +/** Strings longer than this (or containing newlines) collapse to one line. */ +const INLINE_STRING_MAX = 120 + +type Container = Record | unknown[] + +function isContainer(value: unknown): value is Container { + return typeof value === "object" && value !== null +} + +function entriesOf(value: Container): [string, unknown][] { + return Array.isArray(value) + ? value.map((item, index) => [String(index), item]) + : Object.entries(value) +} + +/** + * Node count, bailing out as soon as the cap is passed — a deliberately cheap + * check so a 10 MB result never gets fully walked. + */ +export function countJsonNodes(value: unknown, cap = MAX_NODES): number { + let seen = 0 + const walk = (node: unknown): void => { + if (seen > cap) return + seen += 1 + if (!isContainer(node)) return + for (const [, child] of entriesOf(node)) { + if (seen > cap) return + walk(child) + } + } + walk(value) + return seen +} + +/** + * Parse text that is expected to be a JSON object or array. Returns `undefined` + * for anything else (scalars included — a bare `"text"` or `42` reads better as + * itself than as a one-row tree). + */ +export function parseJsonForTree(text: string): unknown | undefined { + const trimmed = text.trimStart() + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined + try { + const parsed: unknown = JSON.parse(trimmed) + return isContainer(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +/** + * Paths (`""` is the root, then `"a"`, `"a.0"`, …) to expand by default: + * breadth-first until the visible row count would exceed `AUTO_EXPAND_ROWS`. + */ +function defaultExpanded(root: unknown): Set { + const expanded = new Set() + if (!isContainer(root)) return expanded + + let rows = 0 + let frontier: [string, Container][] = [["", root]] + while (frontier.length > 0) { + const next: [string, Container][] = [] + for (const [path, node] of frontier) { + const children = entriesOf(node) + // The root is always opened (its own row is the header, so only its + // children cost rows). A container that would blow the budget stays + // collapsed, but its siblings still get their chance. + if (rows + children.length > AUTO_EXPAND_ROWS && path !== "") continue + rows += children.length + expanded.add(path) + for (const [key, child] of children) { + if (isContainer(child)) { + next.push([path === "" ? key : `${path}.${key}`, child]) + } + } + } + frontier = next + } + return expanded +} + +function ValueText({ value }: { value: unknown }) { + if (typeof value === "string") return + if (typeof value === "number") { + return {value} + } + if (typeof value === "boolean") { + return ( + + {String(value)} + + ) + } + return null +} + +function StringValue({ value }: { value: string }) { + const [expanded, setExpanded] = useState(false) + const isLong = value.length > INLINE_STRING_MAX || value.includes("\n") + const className = "text-emerald-700 dark:text-emerald-400" + + if (!isLong) return "{value}" + + if (expanded) { + return ( + + ) + } + + const preview = value.replace(/\s+/g, " ").slice(0, INLINE_STRING_MAX) + return ( + + ) +} + +interface RowProps { + path: string + label: string | null + value: unknown + depth: number + expanded: Set + onToggle: (path: string) => void +} + +function JsonRow({ path, label, value, depth, expanded, onToggle }: RowProps) { + const t = useTranslations("Folder.chat.jsonTree") + + if (!isContainer(value)) { + return ( +
+ {label !== null && ( + + {label}: + + )} + + + +
+ ) + } + + const children = entriesOf(value) + const isOpen = expanded.has(path) + const summary = Array.isArray(value) + ? t("items", { count: children.length }) + : t("fields", { count: children.length }) + + return ( +
+ + {isOpen && + children.map(([key, child]) => ( + + ))} +
+ ) +} + +export interface JsonTreeViewProps { + /** Already-parsed JSON value. */ + value: unknown + /** + * Source text for the "raw" toggle. Defaults to a pretty-print of `value`; + * pass the original when it matters (key order, formatting). + */ + rawText?: string + className?: string +} + +export const JsonTreeView = memo(function JsonTreeView({ + value, + rawText, + className, +}: JsonTreeViewProps) { + const t = useTranslations("Folder.chat.jsonTree") + const [showRaw, setShowRaw] = useState(false) + const [expanded, setExpanded] = useState(() => defaultExpanded(value)) + + const onToggle = useCallback((path: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (!next.delete(path)) next.add(path) + return next + }) + }, []) + + const raw = useMemo( + () => rawText ?? JSON.stringify(value, null, 2), + [rawText, value] + ) + const tooBig = useMemo(() => countJsonNodes(value) > MAX_NODES, [value]) + + if (tooBig || !isContainer(value)) { + return + } + + return ( +
+ + {showRaw ? ( + + ) : ( +
+ +
+ )} +
+ ) +}) diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx index a6bacf4d95..fb93a10fb5 100644 --- a/src/components/ai-elements/tool.tsx +++ b/src/components/ai-elements/tool.tsx @@ -22,6 +22,7 @@ import { useTranslations } from "next-intl" import { isValidElement } from "react" import { CodeBlock } from "./code-block" +import { JsonTreeView, parseJsonForTree } from "./json-tree" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { MessageResponse } from "./message" @@ -368,14 +369,18 @@ export const ToolOutput = ({ let Output =
{output as ReactNode}
if (typeof output === "object" && !isValidElement(output)) { - Output = ( - - ) + // Unadapted structured result: a collapsible tree instead of a wall of + // pretty-printed JSON (`JsonTreeView` falls back to the code block itself + // for scalars and oversized payloads). + Output = } else if (typeof output === "string") { const lang = detectOutputLanguage(output) + const jsonValue = lang === "json" ? parseJsonForTree(output) : undefined const shouldRenderMd = renderAsMarkdown ?? (lang === "log" && looksLikeMarkdown(output)) - if (shouldRenderMd) { + if (jsonValue !== undefined) { + Output = + } else if (shouldRenderMd) { Output = (
{output} diff --git a/src/components/message/codex-script-tool-card.test.tsx b/src/components/message/codex-script-tool-card.test.tsx new file mode 100644 index 0000000000..a759d62897 --- /dev/null +++ b/src/components/message/codex-script-tool-card.test.tsx @@ -0,0 +1,131 @@ +import { type ReactNode } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +/** + * Codex "code mode" cards. + * + * Codex now wraps every tool call in a JS script. The history parser + * (`src-tauri/src/parsers/codex_code_mode.rs`) recovers the inner + * `tools.(…)` calls so the ordinary cards keep working, and emits a + * `codex_script` card only for the scripts it could not decompose. Before that, + * every codex history card was titled `const r = await tools.exec_command({` + * and rendered the JS as a shell command. + */ + +vi.mock("@/components/ai-elements/link-safety", () => ({ + FilePathLink: ({ + filePath, + children, + }: { + filePath: string + children: ReactNode + }) => , + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +vi.mock("@/components/ai-elements/code-block", () => ({ + CodeBlock: ({ code, language }: { code: string; language?: string }) => ( +
+      {code}
+    
+ ), +})) + +vi.mock("@/components/ai-elements/message", () => ({ + MessageResponse: ({ children }: { children: string }) => ( +
{children}
+ ), +})) + +import { ContentPartsRenderer } from "./content-parts-renderer" +import enMessages from "@/i18n/messages/en.json" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" +import { CODEX_SCRIPT_TOOL_NAME } from "@/lib/codex-code-mode" + +function renderParts(parts: AdaptedContentPart[], expand = true) { + const result = render( + + + + ) + if (expand) fireEvent.click(screen.getAllByRole("button")[0]) + return result +} + +const SCRIPT_SOURCE = `const cmds = [ + {cmd: "cargo test", workdir: "/repo"}, + {cmd: "cargo clippy", workdir: "/repo"} +]; +const rs = await Promise.all(cmds.map(c => tools.exec_command(c)));` + +function scriptPart( + card: { source: string; title?: string; call_count?: number }, + output: string +): AdaptedContentPart { + return { + type: "tool-call", + toolCallId: "tc-script", + toolName: CODEX_SCRIPT_TOOL_NAME, + input: JSON.stringify(card), + state: "output-available", + output, + } +} + +describe("codex code-mode cards", () => { + it("renders an undecomposable script as JS, titled by its first command", () => { + renderParts([ + scriptPart( + { source: SCRIPT_SOURCE, title: "cargo test", call_count: 1 }, + "test result: ok. 12 passed" + ), + ]) + + expect(screen.getByText("cargo test")).toBeInTheDocument() + // First block is the input (the script); the second is the output body. + const block = screen.getAllByTestId("code-block")[0] + expect(block).toHaveAttribute("data-language", "javascript") + expect(block.textContent).toContain("Promise.all") + expect(screen.getByText("1 tool call")).toBeInTheDocument() + expect(document.body.textContent).toContain("test result: ok. 12 passed") + }) + + it("falls back to a plain Script label when no command could be read", () => { + renderParts([scriptPart({ source: "text('hi');", call_count: 0 }, "hi")]) + + expect(screen.getByText("Script")).toBeInTheDocument() + // The count line is suppressed when the script called no tools at all. + expect(document.body.textContent).not.toContain("tool call") + }) + + it("never renders the script as a shell command", () => { + const { container } = renderParts([ + scriptPart({ source: SCRIPT_SOURCE, title: "cargo test" }, "done"), + ]) + + // The terminal card prefixes commands with `$ `; a JS script must not get + // one, which is exactly what the `exec` → "bash" freeform match produced. + expect(container.textContent).not.toContain("$ const") + }) + + it("renders an unwrapped inner call as its own tool card", () => { + // What the parser emits for `tools.exec_command({cmd: "git status"})`: + // the pre-code-mode shape (bare command), so the Terminal card matches. + renderParts([ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "exec_command", + input: "git status --short", + state: "output-available", + output: " M src/main.rs", + }, + ]) + + expect(screen.getByText("git status --short")).toBeInTheDocument() + expect(document.body.textContent).toContain(" M src/main.rs") + expect(document.body.textContent).not.toContain("await tools.") + }) +}) diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index e383034c02..5cb0b00b96 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -34,6 +34,7 @@ import { } from "@/components/ai-elements/tool" import { Terminal } from "@/components/ai-elements/terminal" import { CodeBlock } from "@/components/ai-elements/code-block" +import { JsonTreeView } from "@/components/ai-elements/json-tree" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { generateUnifiedDiff } from "@/lib/unified-diff-generator" import { FilePathLink } from "@/components/ai-elements/link-safety" @@ -52,6 +53,18 @@ import { import { FeedbackCheckResultCard } from "./feedback-check-result-card" import { SearchResultsOutput } from "./search-results-output" import { parseCodexCommandEnvelope } from "@/lib/codex-command-action" +import { + CODEX_SCRIPT_TOOL_NAME, + parseCodexScriptCard, + type CodexScriptCard, +} from "@/lib/codex-code-mode" +import { + describeStdinChars, + extractAnnouncedSessionId, + isShellSessionToolName, + parseShellSessionInput, + WAIT_TOOL_NAME, +} from "@/lib/shell-session-tool" import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" import { DelegatedSubThread } from "./delegated-sub-thread" import { DelegationStatusCard } from "./delegation-status-card" @@ -80,7 +93,10 @@ import { WrenchIcon, ChevronRightIcon, BrainIcon, + CodeIcon, + KeyboardIcon, MessageCircleQuestionMarkIcon, + TimerIcon, UsersIcon, } from "lucide-react" @@ -848,6 +864,18 @@ function getToolIcon( return if (name === "bash" || name === "exec_command") return + if (name === CODEX_SCRIPT_TOOL_NAME) + return + if (isShellSessionToolName(name)) { + // Sending keystrokes reads as input; everything else (including the 99% of + // `write_stdin` calls that send nothing) is a poll on a running session. + const chars = parseShellSessionInput(input ?? null)?.chars + return chars ? ( + + ) : ( + + ) + } if (name === "apply_patch") return if (name === "glob" || name === "grep") return @@ -930,6 +958,37 @@ function deriveToolTitle( if (name === "notebookedit") return `NotebookEdit ${sp}` } + // Codex code-mode script that could not be decomposed: title from the first + // command the parser could read, else a plain "Script" label. + if (name === CODEX_SCRIPT_TOOL_NAME) { + const card = parseCodexScriptCard(input) + if (card?.title) { + return ellipsis(simplifyShellCommand(card.title).split("\n")[0], 80) + } + return "Script" + } + + // codex unified-exec session tools: neither carries a command, so the bash + // branch below came up empty and the header fell back to the bare tool name + // ("wait" / "bash"). Title them by the command whose session they address — + // resolved by the history parser, see `shell-session-tool.ts`. + if (isShellSessionToolName(name)) { + const call = parseShellSessionInput(input) + if (call) { + if (call.chars) return `Stdin ${describeStdinChars(call.chars)}` + const verb = + name === WAIT_TOOL_NAME && call.terminate ? "Terminate" : "Wait" + if (call.command) { + return ellipsis( + `${verb} ${simplifyShellCommand(call.command).split("\n")[0]}`, + 80 + ) + } + if (call.sessionId) return `${verb} cell ${call.sessionId}` + } + return null + } + // Command tools if (name === "bash" || name === "exec_command") { const description = getField("description") @@ -1128,6 +1187,7 @@ function localizeDerivedToolTitle( if (title === "Edit") return t("title.edit") if (title === "Command") return t("title.command") + if (title === "Script") return t("title.script") if (title === "TodoWrite") return t("title.todoWrite") if (title === "Read") return t("title.read") if (title === "Write") return t("title.write") @@ -1160,6 +1220,26 @@ function localizeDerivedToolTitle( }) } + // codex session tools. The `cell` forms must be tried first: they are the + // fallback shape for an unresolved session id, and `^Wait (.+)$` would eat + // them. + const waitCell = title.match(/^Wait cell (\d+)$/) + if (waitCell) return t("title.waitCell", { id: waitCell[1] }) + + const waitCommand = title.match(/^Wait (.+)$/) + if (waitCommand) return t("title.waitCommand", { command: waitCommand[1] }) + + const terminateCell = title.match(/^Terminate cell (\d+)$/) + if (terminateCell) return t("title.terminateCell", { id: terminateCell[1] }) + + const terminateCommand = title.match(/^Terminate (.+)$/) + if (terminateCommand) { + return t("title.terminateCommand", { command: terminateCommand[1] }) + } + + const stdinChars = title.match(/^Stdin (.+)$/) + if (stdinChars) return t("title.stdinChars", { chars: stdinChars[1] }) + const globWithPattern = title.match(/^Glob (.+)$/) if (globWithPattern) { return t("title.globWithPattern", { pattern: globWithPattern[1] }) @@ -1658,6 +1738,25 @@ function TodoWriteToolInput({ input }: { input: Record }) { return } +/** + * Codex code-mode script the history parser could not decompose into real tool + * calls — show the JS source it ran instead of pretending it was a shell + * command. See `lib/codex-code-mode.ts`. + */ +function CodexScriptToolInput({ card }: { card: CodexScriptCard }) { + const t = useTranslations("Folder.chat.contentParts") + return ( +
+ + {card.callCount > 0 && ( +
+ {t("scriptToolCalls", { count: card.callCount })} +
+ )} +
+ ) +} + function ApplyPatchToolInput({ input }: { input: string }) { return } @@ -1675,7 +1774,13 @@ const CODE_FIELDS = new Set([ ]) /** Fields to hide */ -const HIDDEN_FIELDS = new Set(["dangerouslyDisableSandbox"]) +const HIDDEN_FIELDS = new Set([ + "dangerouslyDisableSandbox", + // Resolved by the history parser purely to title the card + // (`shell-session-tool.ts`); repeating it as a parameter row would just + // duplicate the header. + "session_command", +]) function GenericToolInput({ input }: { input: string }) { const t = useTranslations("Folder.chat.contentParts") @@ -1731,12 +1836,11 @@ function GenericToolInput({ input }: { input: string }) { } if (value !== null && value !== undefined) { + // Nested structured value on a tool with no dedicated card — same + // unreadable-JSON-wall problem the result panel had. return ( - + ) } @@ -1775,6 +1879,11 @@ function StructuredToolInput({
) : null + if (name === CODEX_SCRIPT_TOOL_NAME) { + const card = parseCodexScriptCard(input) + if (card) return + } + if (name === "apply_patch") { const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) ?? input @@ -2121,8 +2230,16 @@ const ToolCallPart = memo(function ToolCallPart({ [part.toolName] ) const toolNameLower = normalizedToolName.toLowerCase() + // codex's `wait` / `write_stdin` continue a background shell, so they share + // the command path: same output envelope to strip, same Terminal body. What + // they must NOT share is the `$ ` prompt line — the command already + // names the card, and reprinting it would read as a re-run (see + // `displayCommand` below). + const isShellSessionTool = isShellSessionToolName(toolNameLower) const isCommandTool = - toolNameLower === "bash" || toolNameLower === "exec_command" + toolNameLower === "bash" || + toolNameLower === "exec_command" || + isShellSessionTool const isCommandLikeTool = isCommandTool || toolNameLower === "apply_patch" const isSearchTool = toolNameLower === "grep" || toolNameLower === "glob" const isRunning = @@ -2207,14 +2324,34 @@ const ToolCallPart = memo(function ToolCallPart({ if (sec < 60) return `${sec.toFixed(1)}s` return `${(sec / 60).toFixed(1)}m` }, [part.output, part.errorText]) + // The background shell this command left running, when its own script echoed + // the id (see `extractAnnouncedSessionId`). One session is polled by any + // number of later `wait` cards, and the ones this echo comes with are exactly + // the ones the parser could not title by a command — so naming the session + // here is what ties those "Wait cell 17983" cards back to the command that + // started it. + const announcedSessionId = useMemo( + () => + toolNameLower === "exec_command" + ? extractAnnouncedSessionId(part.output ?? part.errorText) + : null, + [toolNameLower, part.output, part.errorText] + ) const titleSuffix = useMemo(() => { const hasStats = lineChangeStats && (lineChangeStats.additions > 0 || lineChangeStats.deletions > 0) - if (!hasStats && !wallTime && !backgroundLaunch) return null + if (!hasStats && !wallTime && !backgroundLaunch && !announcedSessionId) { + return null + } return ( + {announcedSessionId && ( + + {t("shellSession", { id: announcedSessionId })} + + )} {backgroundLaunch && ( ) - }, [lineChangeStats, wallTime, backgroundLaunch, t]) + }, [lineChangeStats, wallTime, backgroundLaunch, announcedSessionId, t]) const icon = useMemo( () => getToolIcon(normalizedToolName, part.input), [normalizedToolName, part.input] ) const displayCommand = useMemo(() => { - if (!isCommandTool) return null + if (!isCommandTool || isShellSessionTool) return null return ( extractDisplayCommandFromToolInput(part.input) ?? extractDisplayCommandFromToolInput(part.output) ?? extractDisplayCommandFromToolInput(part.errorText) ) - }, [isCommandTool, part.input, part.output, part.errorText]) + }, [ + isCommandTool, + isShellSessionTool, + part.input, + part.output, + part.errorText, + ]) const commandOutput = useMemo(() => { if (!isCommandLikeTool) return null const source = diff --git a/src/components/message/shell-session-tool-card.test.tsx b/src/components/message/shell-session-tool-card.test.tsx new file mode 100644 index 0000000000..a94be558e4 --- /dev/null +++ b/src/components/message/shell-session-tool-card.test.tsx @@ -0,0 +1,171 @@ +import { type ReactNode } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +/** + * Codex unified-exec session cards (`wait` / `write_stdin`). + * + * Both address a background shell started by an earlier `exec_command`, by a + * session id — no command of their own. That left `wait` titled "wait" and + * `write_stdin` (which aliased to "bash") titled "bash", over a dump of + * `cell_id / yield_time_ms / max_tokens`. The history parser now resolves the + * id back to the command as `session_command`; these tests pin the card that + * reads it. See `src/lib/shell-session-tool.ts`. + */ + +vi.mock("@/components/ai-elements/link-safety", () => ({ + FilePathLink: ({ + filePath, + children, + }: { + filePath: string + children: ReactNode + }) => , + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +vi.mock("@/components/ai-elements/message", () => ({ + MessageResponse: ({ children }: { children: string }) => ( +
{children}
+ ), +})) + +import { ContentPartsRenderer } from "./content-parts-renderer" +import enMessages from "@/i18n/messages/en.json" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" + +function renderParts(parts: AdaptedContentPart[]) { + const result = render( + + + + ) + fireEvent.click(screen.getAllByRole("button")[0]) + return result +} + +function sessionPart( + toolName: string, + input: Record, + output = "" +): AdaptedContentPart { + return { + type: "tool-call", + toolCallId: "call_1", + toolName, + input: JSON.stringify(input), + state: "output-available", + output, + } +} + +function execPart(command: string, output: string): AdaptedContentPart { + return { + type: "tool-call", + toolCallId: "call_1", + toolName: "exec_command", + // `input_preview` of a codex `exec_command` is the bare command string. + input: command, + state: "output-available", + output, + } +} + +describe("codex shell-session cards", () => { + it("titles a wait by the command whose session it polls", () => { + const { container } = renderParts([ + sessionPart( + "wait", + { + cell_id: "7106", + yield_time_ms: 30000, + max_tokens: 20000, + session_command: "cargo test --features test-utils", + }, + "test result: ok. 1962 passed" + ), + ]) + + expect( + screen.getByText("Wait cargo test --features test-utils") + ).toBeInTheDocument() + expect(container.textContent).toContain("test result: ok. 1962 passed") + // The command names the card; reprinting it as a shell prompt would read + // as a re-run of the command rather than a poll on its session. + expect(container.textContent).not.toContain("$ cargo test") + // …and the parameter dump the card used to be is gone. + expect(container.textContent).not.toContain("yield_time_ms") + }) + + it("falls back to the session id when the command is unknown", () => { + renderParts([ + sessionPart("wait", { cell_id: "7106", yield_time_ms: 30000 }, "still…"), + ]) + + expect(screen.getByText("Wait cell 7106")).toBeInTheDocument() + }) + + it("renders a terminating wait as a terminate, not a wait", () => { + renderParts([ + sessionPart( + "wait", + { cell_id: "42", terminate: true, session_command: "pnpm dev" }, + "" + ), + ]) + + expect(screen.getByText("Terminate pnpm dev")).toBeInTheDocument() + }) + + it("treats an empty write_stdin as the poll it is", () => { + const { container } = renderParts([ + sessionPart( + "write_stdin", + { session_id: 33067, chars: "", session_command: "pnpm test" }, + " RUN v2.1.9" + ), + ]) + + expect(screen.getByText("Wait pnpm test")).toBeInTheDocument() + expect(container.textContent).toContain("RUN v2.1.9") + // Regression: `write_stdin` aliased to "bash", whose title derivation found + // no command in `{session_id, chars}` and fell back to the raw tool name. + expect(container.textContent).not.toContain("bash") + }) + + it("shows the keystrokes an interrupting write_stdin sent", () => { + renderParts([ + sessionPart("write_stdin", { + session_id: 33067, + chars: "\u0003", + session_command: "pnpm dev", + }), + ]) + + expect(screen.getByText("Stdin ^C")).toBeInTheDocument() + }) + + it("names the session a command left running next to its title", () => { + // One session is polled by any number of `wait` cards, and this echo is + // the shape the parser cannot resolve — so both ends have to show the id + // for them to be matched up: "Wait cell 17983" against this card. + renderParts([ + execPart( + "npx --yes playwright@1.62.1 install chromium", + "\nSESSION_ID=17983" + ), + ]) + + expect( + screen.getByText("npx --yes playwright@1.62.1 install chromium") + ).toBeInTheDocument() + expect(screen.getByText("Session 17983")).toBeInTheDocument() + }) + + it("leaves a command that started no session unlabelled", () => { + renderParts([execPart("cargo build", "Compiling codeg v0.22.2")]) + + expect(screen.queryByText(/^Session /)).toBeNull() + }) +}) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index eec39a55c4..f79b2e608a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value}ث", "toolUseCount": "{count} استخدام للأداة" }, + "jsonTree": { + "viewRaw": "عرض JSON الخام", + "viewTree": "عرض الشجرة", + "fields": "{count, plural, zero {لا حقول} one {حقل واحد} two {حقلان} few {# حقول} many {# حقلًا} other {# حقل}}", + "items": "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}}" + }, "tool": { "parameters": "المعلمات", "error": "خطأ", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} ملف إضافي", "timeoutMs": "المهلة: {timeout}ms", "backgroundTrue": "الخلفية: true", + "scriptToolCalls": "{count, plural, zero {صفر استدعاء أداة} one {استدعاء أداة واحد} two {استدعاءا أداة} few {# استدعاءات أداة} many {# استدعاء أداة} other {# استدعاء أداة}}", "offset": "الإزاحة: {offset}", "limit": "الحد: {limit}", "pages": "الصفحات: {pages}", "mode": "الوضع: {mode}", "cell": "الخلية: {cell}", + "shellSession": "الجلسة {id}", "pathLabel": "المسار:", "globLabel": "نمط glob:", "typeLabel": "النوع:", @@ -2805,6 +2813,12 @@ "title": { "edit": "تحرير", "command": "أمر", + "script": "سكربت", + "waitCommand": "انتظار {command}", + "waitCell": "انتظار الجلسة {id}", + "terminateCommand": "إنهاء {command}", + "terminateCell": "إنهاء الجلسة {id}", + "stdinChars": "إدخال {chars}", "todoWrite": "TodoWrite (تحديث المهام)", "read": "قراءة", "write": "كتابة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index fa219638ff..a92e2e0352 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} Sek", "toolUseCount": "{count, plural, one {# Tool-Aufruf} other {# Tool-Aufrufe}}" }, + "jsonTree": { + "viewRaw": "Rohes JSON anzeigen", + "viewTree": "Baum anzeigen", + "fields": "{count, plural, one {# Feld} other {# Felder}}", + "items": "{count, plural, one {# Eintrag} other {# Einträge}}" + }, "tool": { "parameters": "Parameter", "error": "Fehler", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} weitere Dateien", "timeoutMs": "Zeitlimit: {timeout}ms", "backgroundTrue": "Hintergrund: true", + "scriptToolCalls": "{count, plural, one {# Tool-Aufruf} other {# Tool-Aufrufe}}", "offset": "Versatz: {offset}", "limit": "Grenze: {limit}", "pages": "Seiten: {pages}", "mode": "Modus: {mode}", "cell": "Zelle: {cell}", + "shellSession": "Sitzung {id}", "pathLabel": "Pfad:", "globLabel": "Glob-Muster:", "typeLabel": "Typ:", @@ -2805,6 +2813,12 @@ "title": { "edit": "Bearbeiten", "command": "Befehl", + "script": "Skript", + "waitCommand": "Warten {command}", + "waitCell": "Sitzung {id} abwarten", + "terminateCommand": "Beenden {command}", + "terminateCell": "Sitzung {id} beenden", + "stdinChars": "Eingabe {chars}", "todoWrite": "TodoWrite (Aufgaben aktualisieren)", "read": "Lesen", "write": "Schreiben", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1ba2fca70c..66f518dd76 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value}s", "toolUseCount": "{count} tool {count, plural, one {use} other {uses}}" }, + "jsonTree": { + "viewRaw": "Show raw JSON", + "viewTree": "Show tree", + "fields": "{count, plural, one {# field} other {# fields}}", + "items": "{count, plural, one {# item} other {# items}}" + }, "tool": { "parameters": "Parameters", "error": "Error", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} more files", "timeoutMs": "Timeout: {timeout}ms", "backgroundTrue": "Background: true", + "scriptToolCalls": "{count, plural, one {# tool call} other {# tool calls}}", "offset": "Offset: {offset}", "limit": "Limit: {limit}", "pages": "Pages: {pages}", "mode": "Mode: {mode}", "cell": "Cell: {cell}", + "shellSession": "Session {id}", "pathLabel": "Path:", "globLabel": "Glob:", "typeLabel": "Type:", @@ -2805,6 +2813,12 @@ "title": { "edit": "Edit", "command": "Command", + "script": "Script", + "waitCommand": "Wait {command}", + "waitCell": "Wait cell {id}", + "terminateCommand": "Terminate {command}", + "terminateCell": "Terminate cell {id}", + "stdinChars": "Stdin {chars}", "todoWrite": "TodoWrite", "read": "Read", "write": "Write", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 59c1595db7..e5f9ad1687 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} s", "toolUseCount": "{count, plural, one {# uso de herramienta} other {# usos de herramienta}}" }, + "jsonTree": { + "viewRaw": "Ver JSON sin formato", + "viewTree": "Ver árbol", + "fields": "{count, plural, one {# campo} other {# campos}}", + "items": "{count, plural, one {# elemento} other {# elementos}}" + }, "tool": { "parameters": "Parámetros", "error": "Error de herramienta", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} archivos más", "timeoutMs": "Tiempo de espera: {timeout}ms", "backgroundTrue": "Segundo plano: true", + "scriptToolCalls": "{count, plural, one {# llamada a herramienta} other {# llamadas a herramientas}}", "offset": "Desplazamiento: {offset}", "limit": "Límite: {limit}", "pages": "Páginas: {pages}", "mode": "Modo: {mode}", "cell": "Celda: {cell}", + "shellSession": "Sesión {id}", "pathLabel": "Ruta:", "globLabel": "Patrón glob:", "typeLabel": "Tipo:", @@ -2805,6 +2813,12 @@ "title": { "edit": "Editar", "command": "Comando", + "script": "Script", + "waitCommand": "Esperar {command}", + "waitCell": "Esperar sesión {id}", + "terminateCommand": "Terminar {command}", + "terminateCell": "Terminar sesión {id}", + "stdinChars": "Entrada {chars}", "todoWrite": "TodoWrite (actualización de tareas)", "read": "Leer", "write": "Escribir", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 2bfac40e3f..c0b7f46d83 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} s", "toolUseCount": "{count, plural, one {# appel d'outil} other {# appels d'outil}}" }, + "jsonTree": { + "viewRaw": "Afficher le JSON brut", + "viewTree": "Afficher l'arborescence", + "fields": "{count, plural, one {# champ} other {# champs}}", + "items": "{count, plural, one {# élément} other {# éléments}}" + }, "tool": { "parameters": "Paramètres", "error": "Erreur", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} fichiers supplémentaires", "timeoutMs": "Délai d'expiration : {timeout}ms", "backgroundTrue": "Arrière-plan : true", + "scriptToolCalls": "{count, plural, one {# appel d'outil} other {# appels d'outil}}", "offset": "Décalage : {offset}", "limit": "Limite : {limit}", "pages": "Pages : {pages}", "mode": "Mode : {mode}", "cell": "Cellule : {cell}", + "shellSession": "Session {id}", "pathLabel": "Chemin :", "globLabel": "Glob :", "typeLabel": "Type :", @@ -2805,6 +2813,12 @@ "title": { "edit": "Modifier", "command": "Commande", + "script": "Script", + "waitCommand": "Attendre {command}", + "waitCell": "Attendre la session {id}", + "terminateCommand": "Terminer {command}", + "terminateCell": "Terminer la session {id}", + "stdinChars": "Entrée {chars}", "todoWrite": "TodoWrite (mise à jour des tâches)", "read": "Lire", "write": "Écrire", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 79743122eb..90c23661d4 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value}秒", "toolUseCount": "{count} 回のツール使用" }, + "jsonTree": { + "viewRaw": "生の JSON を表示", + "viewTree": "ツリー表示", + "fields": "{count} 個のフィールド", + "items": "{count} 件" + }, "tool": { "parameters": "パラメーター", "error": "エラー", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} 件の追加ファイル", "timeoutMs": "タイムアウト: {timeout}ms", "backgroundTrue": "バックグラウンド: true", + "scriptToolCalls": "{count} 個のツールを呼び出し", "offset": "オフセット: {offset}", "limit": "上限: {limit}", "pages": "ページ: {pages}", "mode": "モード: {mode}", "cell": "セル: {cell}", + "shellSession": "セッション {id}", "pathLabel": "パス:", "globLabel": "Glob パターン:", "typeLabel": "タイプ:", @@ -2805,6 +2813,12 @@ "title": { "edit": "編集", "command": "コマンド", + "script": "スクリプト", + "waitCommand": "待機 {command}", + "waitCell": "セッション {id} を待機", + "terminateCommand": "停止 {command}", + "terminateCell": "セッション {id} を停止", + "stdinChars": "入力 {chars}", "todoWrite": "TodoWrite(タスク更新)", "read": "読み取り", "write": "書き込み", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 005864a8e4..8ffbcf7293 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value}초", "toolUseCount": "도구 {count}회 사용" }, + "jsonTree": { + "viewRaw": "원본 JSON 보기", + "viewTree": "트리 보기", + "fields": "필드 {count}개", + "items": "항목 {count}개" + }, "tool": { "parameters": "매개변수", "error": "오류", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count}개 파일 더", "timeoutMs": "시간 초과: {timeout}ms", "backgroundTrue": "백그라운드: true", + "scriptToolCalls": "도구 {count}개 호출", "offset": "오프셋: {offset}", "limit": "제한: {limit}", "pages": "페이지: {pages}", "mode": "모드: {mode}", "cell": "셀: {cell}", + "shellSession": "세션 {id}", "pathLabel": "경로:", "globLabel": "Glob 패턴:", "typeLabel": "유형:", @@ -2805,6 +2813,12 @@ "title": { "edit": "편집", "command": "명령", + "script": "스크립트", + "waitCommand": "대기 {command}", + "waitCell": "세션 {id} 대기", + "terminateCommand": "종료 {command}", + "terminateCell": "세션 {id} 종료", + "stdinChars": "입력 {chars}", "todoWrite": "TodoWrite(할 일 업데이트)", "read": "읽기", "write": "쓰기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 0b8a1d75d2..67164e6137 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} s", "toolUseCount": "{count, plural, one {# uso de ferramenta} other {# usos de ferramenta}}" }, + "jsonTree": { + "viewRaw": "Ver JSON bruto", + "viewTree": "Ver árvore", + "fields": "{count, plural, one {# campo} other {# campos}}", + "items": "{count, plural, one {# item} other {# itens}}" + }, "tool": { "parameters": "Parâmetros", "error": "Erro", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} arquivos a mais", "timeoutMs": "Tempo limite: {timeout}ms", "backgroundTrue": "Segundo plano: true", + "scriptToolCalls": "{count, plural, one {# chamada de ferramenta} other {# chamadas de ferramenta}}", "offset": "Deslocamento: {offset}", "limit": "Limite: {limit}", "pages": "Páginas: {pages}", "mode": "Modo: {mode}", "cell": "Célula: {cell}", + "shellSession": "Sessão {id}", "pathLabel": "Caminho:", "globLabel": "Padrão glob:", "typeLabel": "Tipo:", @@ -2805,6 +2813,12 @@ "title": { "edit": "Editar", "command": "Comando", + "script": "Script", + "waitCommand": "Aguardar {command}", + "waitCell": "Aguardar sessão {id}", + "terminateCommand": "Encerrar {command}", + "terminateCell": "Encerrar sessão {id}", + "stdinChars": "Entrada {chars}", "todoWrite": "TodoWrite (atualização de tarefas)", "read": "Ler", "write": "Escrever", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 63a42d27c4..1ba2b62854 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} 秒", "toolUseCount": "{count} 次工具调用" }, + "jsonTree": { + "viewRaw": "查看原始 JSON", + "viewTree": "查看树状视图", + "fields": "{count} 个字段", + "items": "{count} 项" + }, "tool": { "parameters": "参数", "error": "错误", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} 个更多文件", "timeoutMs": "超时:{timeout}ms", "backgroundTrue": "后台:true", + "scriptToolCalls": "调用 {count} 个工具", "offset": "偏移:{offset}", "limit": "限制:{limit}", "pages": "页码:{pages}", "mode": "模式:{mode}", "cell": "单元:{cell}", + "shellSession": "会话 {id}", "pathLabel": "路径:", "globLabel": "Glob:", "typeLabel": "类型:", @@ -2805,6 +2813,12 @@ "title": { "edit": "编辑", "command": "命令", + "script": "脚本", + "waitCommand": "等待 {command}", + "waitCell": "等待会话 {id}", + "terminateCommand": "终止 {command}", + "terminateCell": "终止会话 {id}", + "stdinChars": "输入 {chars}", "todoWrite": "待办", "read": "读取", "write": "写入", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b2181a616d..63ec4f426e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2655,6 +2655,12 @@ "elapsedSeconds": "{value} 秒", "toolUseCount": "{count} 次工具呼叫" }, + "jsonTree": { + "viewRaw": "檢視原始 JSON", + "viewTree": "檢視樹狀檢視", + "fields": "{count} 個欄位", + "items": "{count} 項" + }, "tool": { "parameters": "參數", "error": "錯誤", @@ -2724,11 +2730,13 @@ "moreFiles": "+{count} 個更多檔案", "timeoutMs": "逾時:{timeout}ms", "backgroundTrue": "背景:true", + "scriptToolCalls": "呼叫 {count} 個工具", "offset": "位移:{offset}", "limit": "限制:{limit}", "pages": "頁碼:{pages}", "mode": "模式:{mode}", "cell": "儲存格:{cell}", + "shellSession": "工作階段 {id}", "pathLabel": "路徑:", "globLabel": "Glob:", "typeLabel": "類型:", @@ -2805,6 +2813,12 @@ "title": { "edit": "編輯", "command": "命令", + "script": "腳本", + "waitCommand": "等待 {command}", + "waitCell": "等待工作階段 {id}", + "terminateCommand": "終止 {command}", + "terminateCell": "終止工作階段 {id}", + "stdinChars": "輸入 {chars}", "todoWrite": "待辦", "read": "讀取", "write": "寫入", diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index a7af5d1200..7ab25bf467 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -1,4 +1,5 @@ import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" +import { isShellSessionToolName } from "@/lib/shell-session-tool" export type ToolKindLabel = | "search" @@ -126,7 +127,11 @@ export function classifyToolKind(toolName: string): ToolKindLabel { name === "exec_command" || name === "shell" || name === "execute_command" || - name === "run_command" + name === "run_command" || + // codex's unified-exec session tools continue a background shell started by + // an `exec_command`, so they belong with the commands in the tool-group + // tally (see `shell-session-tool.ts`). + isShellSessionToolName(name) ) { return "command" } diff --git a/src/lib/codex-code-mode.ts b/src/lib/codex-code-mode.ts new file mode 100644 index 0000000000..70bfbbb77d --- /dev/null +++ b/src/lib/codex-code-mode.ts @@ -0,0 +1,49 @@ +/** + * Codex "code mode" script card. + * + * Newer codex wraps every tool call in a JS script and persists the whole + * script as ONE rollout record. The history parser + * (`src-tauri/src/parsers/codex_code_mode.rs`) recovers the inner + * `tools.(…)` calls and re-emits them as the tool calls codex wrote + * before code mode, so the existing cards keep working. + * + * When a script cannot be decomposed (variable destructuring, a command built + * in a loop, template interpolation) the parser emits this synthetic tool + * instead of guessing — its input carries the raw script for a JS code block. + */ +export const CODEX_SCRIPT_TOOL_NAME = "codex_script" + +export interface CodexScriptCard { + /** Raw JS source codex executed. */ + source: string + /** Best-effort title (the first command the parser could read). */ + title: string | null + /** Number of `tools.*` call sites detected in the script. */ + callCount: number +} + +export function parseCodexScriptCard( + input: string | null | undefined +): CodexScriptCard | null { + if (!input) return null + let parsed: unknown + try { + parsed = JSON.parse(input) + } catch { + return null + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return null + + const record = parsed as Record + const source = record.source + if (typeof source !== "string") return null + + const title = typeof record.title === "string" ? record.title : null + const callCount = + typeof record.call_count === "number" && Number.isFinite(record.call_count) + ? record.call_count + : 0 + + return { source, title, callCount } +} diff --git a/src/lib/shell-session-tool.test.ts b/src/lib/shell-session-tool.test.ts new file mode 100644 index 0000000000..3c86b518d5 --- /dev/null +++ b/src/lib/shell-session-tool.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest" + +import { + describeStdinChars, + extractAnnouncedSessionId, + isShellSessionToolName, + parseShellSessionInput, +} from "./shell-session-tool" + +describe("isShellSessionToolName", () => { + it("matches the two codex unified-exec session tools", () => { + expect(isShellSessionToolName("wait")).toBe(true) + expect(isShellSessionToolName("write_stdin")).toBe(true) + // `wait_agent` is the collab tool — a different thing entirely. + expect(isShellSessionToolName("wait_agent")).toBe(false) + expect(isShellSessionToolName("bash")).toBe(false) + }) +}) + +describe("parseShellSessionInput", () => { + it("reads a wait: string cell id plus the resolved command", () => { + expect( + parseShellSessionInput( + '{"cell_id":"7106","yield_time_ms":30000,"session_command":"cargo test"}' + ) + ).toEqual({ + sessionId: "7106", + command: "cargo test", + chars: "", + terminate: false, + }) + }) + + it("reads a write_stdin: numeric session id normalized to a string", () => { + expect( + parseShellSessionInput('{"session_id":33067,"chars":"y\\n"}') + ).toEqual({ + sessionId: "33067", + command: null, + chars: "y\n", + terminate: false, + }) + }) + + it("reports a terminating wait", () => { + expect( + parseShellSessionInput('{"cell_id":"42","terminate":true}')?.terminate + ).toBe(true) + }) + + it("returns null for anything that is not an argument object", () => { + expect(parseShellSessionInput(null)).toBeNull() + expect(parseShellSessionInput("")).toBeNull() + expect(parseShellSessionInput("not json")).toBeNull() + expect(parseShellSessionInput("[1,2]")).toBeNull() + }) +}) + +describe("extractAnnouncedSessionId", () => { + it("reads the id a script echoed after starting a background shell", () => { + // Verbatim from a rollout: `text(r.output)` printed nothing, the echo is + // the whole body of the card. + expect(extractAnnouncedSessionId("\nSESSION_ID=17983")).toBe("17983") + expect(extractAnnouncedSessionId("Installing…\nSESSION_ID=25332\n")).toBe( + "25332" + ) + expect(extractAnnouncedSessionId("SESSION_ID=25332\r\n")).toBe("25332") + }) + + it("ignores anything that is not the announcement line itself", () => { + expect(extractAnnouncedSessionId(null)).toBeNull() + expect(extractAnnouncedSessionId("")).toBeNull() + // The script's own source, grepped back out of the transcript. + expect( + extractAnnouncedSessionId("if (r.session_id) text(`SESSION_ID=${r.id}`)") + ).toBeNull() + // Mentioned mid-line, or carrying something other than an id. + expect( + extractAnnouncedSessionId("export SESSION_ID=17983 && run") + ).toBeNull() + expect(extractAnnouncedSessionId("SESSION_ID=abc")).toBeNull() + }) +}) + +describe("describeStdinChars", () => { + it("renders control characters that would otherwise be invisible", () => { + expect(describeStdinChars("\u0003")).toBe("^C") + expect(describeStdinChars("\u0004")).toBe("^D") + expect(describeStdinChars("\u001b")).toBe("^[") + expect(describeStdinChars("\u007f")).toBe("^?") + }) + + it("keeps ordinary text, showing newlines as a return symbol", () => { + expect(describeStdinChars("y\n")).toBe("y⏎") + }) + + it("truncates a long payload", () => { + expect(describeStdinChars("a".repeat(100))).toHaveLength(40) + }) +}) diff --git a/src/lib/shell-session-tool.ts b/src/lib/shell-session-tool.ts new file mode 100644 index 0000000000..355e8dc8aa --- /dev/null +++ b/src/lib/shell-session-tool.ts @@ -0,0 +1,129 @@ +/** + * Codex unified-exec session tools (`wait` / `write_stdin`). + * + * Neither carries a command of its own: both address a background shell that an + * earlier `exec_command` started, by the id that command's output announced + * (`wait` calls it `cell_id`, `write_stdin` calls it `session_id`). Rendered + * from the raw arguments they were bare `wait` / `bash` cards over a + * `{"cell_id":"7106","yield_time_ms":30000}` parameter dump. + * + * The history parser (`src-tauri/src/parsers/codex.rs`) resolves the id back to + * that command and adds it as `session_command`, so these cards can be titled + * by the command they are waiting on. The field is absent when the id was never + * announced in the transcript (e.g. the session was started by a code-mode + * script whose arguments could not be recovered) — the card then falls back to + * naming the session id. + */ + +/** Canonical (post-`normalizeToolName`) names of the two session tools. */ +export const WAIT_TOOL_NAME = "wait" +export const WRITE_STDIN_TOOL_NAME = "write_stdin" + +export function isShellSessionToolName(toolName: string): boolean { + const name = toolName.toLowerCase() + return name === WAIT_TOOL_NAME || name === WRITE_STDIN_TOOL_NAME +} + +export interface ShellSessionCall { + /** `cell_id` / `session_id`, normalized to a string (codex sends both). */ + sessionId: string | null + /** Command that started the session, resolved by the history parser. */ + command: string | null + /** Keystrokes sent (`write_stdin` only). Empty string means "just poll". */ + chars: string + /** `wait` may ask to kill the session instead of waiting on it. */ + terminate: boolean +} + +function readId(value: unknown): string | null { + if (typeof value === "string" && value.trim().length > 0) return value.trim() + if (typeof value === "number" && Number.isFinite(value)) return String(value) + return null +} + +export function parseShellSessionInput( + input: string | null | undefined +): ShellSessionCall | null { + if (!input) return null + let parsed: unknown + try { + parsed = JSON.parse(input) + } catch { + return null + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null + } + + const record = parsed as Record + const command = record.session_command + const chars = record.chars + return { + sessionId: readId(record.cell_id) ?? readId(record.session_id), + command: + typeof command === "string" && command.trim().length > 0 ? command : null, + chars: typeof chars === "string" ? chars : "", + terminate: record.terminate === true, + } +} + +/** + * The line a code-mode script prints to publish the session it just started: + * + * ```js + * const r = await tools.exec_command({ cmd: "npx playwright install", … }) + * text(r.output) + * if (r.session_id) text(`SESSION_ID=${r.session_id}`) + * ``` + * + * Anchored to the whole line, uppercase, digits only — the single spelling real + * transcripts use (every one of the 95 announcements across 749 rollouts), and + * tight enough that a command merely mentioning the words cannot mint an id. + */ +const ANNOUNCED_SESSION_ID = /^[ \t]*SESSION_ID[ \t]*=[ \t]*(\d+)[ \t\r]*$/m + +/** + * The background session id a command's own output announced. + * + * codex's own announcements never reach the card: the prose envelope is dropped + * with everything above the `Output:` line, and the code-mode JSON one is + * unwrapped to its `output` field. The script's echo above is the only one that + * survives into the body — and it is exactly the case the history parser cannot + * resolve, because it reads those envelopes rather than the echo. So the `wait` + * calls polling such a session stay titled by the bare id ("Wait cell 17983"); + * showing the same id on the command card is what lets the two be matched up. + */ +export function extractAnnouncedSessionId( + output: string | null | undefined +): string | null { + if (!output) return null + return ANNOUNCED_SESSION_ID.exec(output)?.[1] ?? null +} + +const NAMED_CONTROL_CHARS: Record = { + "\n": "⏎", + "\r": "⏎", + "\t": "⇥", + "\u007f": "^?", +} + +/** + * Render keystrokes sent to a session as something readable in a card title — + * the payload is usually a control character (`Ctrl-C` to interrupt, a bare + * newline to confirm a prompt), which would otherwise be invisible. + */ +export function describeStdinChars(chars: string, maxLen = 40): string { + let out = "" + for (const ch of chars) { + const named = NAMED_CONTROL_CHARS[ch] + if (named) { + out += named + continue + } + const code = ch.codePointAt(0) ?? 0 + // Remaining C0 controls render as caret notation (`Ctrl-C` → `^C`). + out += code < 0x20 ? `^${String.fromCharCode(code + 64)}` : ch + } + out = out.trim() + return out.length > maxLen ? `${out.slice(0, maxLen - 1)}…` : out +} diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 758075d1ed..57fda30584 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -2,7 +2,9 @@ import { parseCodexListFilesTitle, parseCodexSearchTitle, } from "@/lib/codex-command-action" +import { CODEX_SCRIPT_TOOL_NAME } from "@/lib/codex-code-mode" import { COLLAB_AGENT_TOOL_NAME, isCodexCollabInput } from "@/lib/collab-tool" +import { WAIT_TOOL_NAME, WRITE_STDIN_TOOL_NAME } from "@/lib/shell-session-tool" const EXACT_TOOL_NAME_ALIASES: Record = { shell_command: "bash", @@ -27,7 +29,6 @@ const EXACT_TOOL_NAME_ALIASES: Record = { change: "edit", "functions.change": "edit", changes: "edit", - write_stdin: "bash", read_file: "read", read_text_file: "read", readfile: "read", @@ -67,6 +68,17 @@ const EXACT_TOOL_NAME_ALIASES: Record = { browser_action: "webfetch", use_mcp_tool: "tool", // Codex + // Code-mode script card (`parsers/codex_code_mode.rs`). MUST be an exact + // alias: the freeform `exec(ute)?` matcher below would otherwise collapse it + // to "bash" and render the JS source as a shell command. + [CODEX_SCRIPT_TOOL_NAME]: CODEX_SCRIPT_TOOL_NAME, + // Unified-exec session tools. They keep their own identity (see + // `shell-session-tool.ts`) instead of collapsing into "bash": their arguments + // carry a session id, not a command, so the Terminal card's title derivation + // came up empty and every one of them rendered as a bare "bash" / "wait". + // Listed explicitly so no future freeform rule can hijack them. + [WAIT_TOOL_NAME]: WAIT_TOOL_NAME, + [WRITE_STDIN_TOOL_NAME]: WRITE_STDIN_TOOL_NAME, spawn_agent: "agent", wait_agent: "task", close_agent: "task", From 9701d9325d6b88ad0422607f1e91fe9c571e4923 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 1 Aug 2026 20:44:10 +0800 Subject: [PATCH 2/2] fix(lint): clear clippy 1.97 errors in the code-mode parser CI lints on the current stable, which rejects three shapes the code-mode work introduced: a manual remainder test for divisibility, the four-field tuple the results-loop matcher carried between its three header forms, and a `format!` with nothing to interpolate in a test. The remainder test becomes `is_multiple_of` -- still guarded by the zero-divisor check ahead of it -- the tuple becomes a named `LoopHeader`, and the test string is a plain literal. No behavior changes. --- src-tauri/src/parsers/codex.rs | 2 +- src-tauri/src/parsers/codex_code_mode.rs | 55 ++++++++++++++++-------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 5b994d9a6d..5d49dadc09 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -558,7 +558,7 @@ fn digit_blind(text: &str) -> String { /// and the one exception is the check's own blind spot — that `nl` failed and /// printed a usage error, so there was no line number to match. fn call_chunk_stride(chunks: &[String], calls: usize, text_run: Option) -> Option { - if calls == 0 || chunks.is_empty() || chunks.len() % calls != 0 { + if calls == 0 || chunks.is_empty() || !chunks.len().is_multiple_of(calls) { return None; } let stride = chunks.len() / calls; diff --git a/src-tauri/src/parsers/codex_code_mode.rs b/src-tauri/src/parsers/codex_code_mode.rs index 4b46d2383e..6a9794f9f5 100644 --- a/src-tauri/src/parsers/codex_code_mode.rs +++ b/src-tauri/src/parsers/codex_code_mode.rs @@ -319,6 +319,16 @@ struct ResultsLoop { binds: Vec, } +/// One header as matched, before the `forEach` form has been opened up into a +/// `ResultsLoop`: where the header sits, what it binds so far, the counter of +/// the index form, and whether the body is still inside a callback. +struct LoopHeader { + header: Range, + binds: Vec, + counter: Option, + callback: bool, +} + /// `None` when the script has no such loop, or more than one. /// /// Three headers, and nothing else: `for (const x of results)`, @@ -339,21 +349,25 @@ fn results_loop(src: &str, results: &str) -> Option { )) .ok()?; - // `header`, then what the loop binds, then whether it is the callback form. - let mut found: Option<(Range, Vec, Option, bool)> = None; + let mut found: Option = None; let mut hits = 0usize; for caps in for_of.captures_iter(src) { hits += 1; - found = Some(( - caps.get(0)?.range(), - vec![caps.get(1)?.as_str().to_string()], - None, - false, - )); + found = Some(LoopHeader { + header: caps.get(0)?.range(), + binds: vec![caps.get(1)?.as_str().to_string()], + counter: None, + callback: false, + }); } for m in for_each.find_iter(src) { hits += 1; - found = Some((m.range(), Vec::new(), None, true)); + found = Some(LoopHeader { + header: m.range(), + binds: Vec::new(), + counter: None, + callback: true, + }); } for caps in for_index.captures_iter(src) { hits += 1; @@ -363,18 +377,23 @@ fn results_loop(src: &str, results: &str) -> Option { if caps.get(2)?.as_str() != counter || caps.get(3)?.as_str() != counter { return None; } - found = Some(( - caps.get(0)?.range(), - vec![counter.to_string()], - Some(counter.to_string()), - false, - )); + found = Some(LoopHeader { + header: caps.get(0)?.range(), + binds: vec![counter.to_string()], + counter: Some(counter.to_string()), + callback: false, + }); } if hits != 1 { return None; } - let (header, mut binds, counter, callback) = found?; + let LoopHeader { + header, + mut binds, + counter, + callback, + } = found?; if callback { // `forEach` hands its callback the array itself as a third argument, so // a callback that takes one holds an alias this proof does not track: @@ -1915,8 +1934,8 @@ mod tests { /// the sampled candidate scripts pass exactly this to `xmllint`. #[test] fn text_inside_a_command_string_is_not_a_call_site() { - let src = format!("const r = await Promise.all([tools.exec_command({{cmd: \"xmllint --html --xpath '//div//text()' -\"}}), tools.exec_command({{cmd:\"b\"}})]);\nfor (const x of r) {{ text(x.output); text(\"done\"); }}\n"); - assert_eq!(parse_code_mode_script(&src).text_run, Some(2)); + let src = "const r = await Promise.all([tools.exec_command({cmd: \"xmllint --html --xpath '//div//text()' -\"}), tools.exec_command({cmd:\"b\"})]);\nfor (const x of r) { text(x.output); text(\"done\"); }\n"; + assert_eq!(parse_code_mode_script(src).text_run, Some(2)); } #[test]