diff --git a/package.json b/package.json index d137237bb..c82133dcf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.22.1", + "version": "0.22.2", "packageManager": "pnpm@11.9.0", "scripts": { "dev": "next dev --turbopack", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index facf24023..9addc5b33 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1014,7 +1014,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.22.1" +version = "0.22.2" dependencies = [ "aes-gcm", "agent-client-protocol-schema", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee2ca16ba..aaeaba96f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.22.1" +version = "0.22.2" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 49ae7d95f..93417ed9f 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -6571,10 +6571,12 @@ pub(crate) fn json_value_to_text(val: &Option) -> Option` instead (see grok_mcp_output_text). Without this a // finished MCP call — e.g. the `delegate_to_agent` ack carrying @@ -9328,6 +9337,38 @@ mod tests { ); } + /// A `get_command_or_subagent_output` poll has no `content[]` and no + /// `output_for_prompt` — its whole result sits under the `TaskOutput` + /// envelope, which used to be dropped, streaming an empty card. Live must + /// emit the SAME string the history parser stores so the background-task + /// card renders identically before and after a reload. + #[test] + fn grok_live_tool_output_emits_task_output_envelope() { + let raw = serde_json::json!({ + "type": "TaskOutput", + "Result": { + "task_id": "term_b0d", + "command": "/bin/bash -lc 'pnpm dev'", + "status": "failed", + "exit_code": 1, + "output": "boom", + }, + }); + let live = grok_live_tool_output(&None, &Some(raw.clone())).expect("envelope emitted"); + assert_eq!( + live, + crate::parsers::grok::grok_task_output_envelope(&raw).unwrap(), + "live and history must hand the frontend the same string" + ); + let parsed: serde_json::Value = serde_json::from_str(&live).unwrap(); + assert_eq!(parsed["Result"]["exit_code"], 1); + // A poll that DOES carry clean content keeps content's precedence. + assert_eq!( + grok_live_tool_output(&Some("已完成".to_string()), &Some(raw)), + None + ); + } + #[test] fn grok_live_tool_output_none_without_usable_string() { // Object without `output_for_prompt` (only the byte-array `output`). diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index e04f6ef5c..397a1b45b 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -329,8 +329,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Cline", description: "Autonomous coding agent CLI", distribution: AgentDistribution::Npx { - version: "3.0.46", - package: "cline@3.0.46", + version: "3.0.47", + package: "cline@3.0.47", cmd: "cline", args: &["--acp"], env: &[], @@ -343,39 +343,39 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "OpenCode", description: "The open source coding agent", distribution: AgentDistribution::Binary { - version: "1.18.8", + version: "1.18.10", cmd: "opencode", args: &["acp"], env: &[], platforms: &[ PlatformBinary { platform: "darwin-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-darwin-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-darwin-arm64.zip", sha256: None, }, PlatformBinary { platform: "darwin-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-darwin-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-darwin-x64.zip", sha256: None, }, PlatformBinary { platform: "linux-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-linux-arm64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-linux-arm64.tar.gz", sha256: None, }, PlatformBinary { platform: "linux-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-linux-x64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-linux-x64.tar.gz", sha256: None, }, PlatformBinary { platform: "windows-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-windows-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-windows-arm64.zip", sha256: None, }, PlatformBinary { platform: "windows-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.18.8/opencode-windows-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.18.10/opencode-windows-x64.zip", sha256: None, }, ], @@ -410,8 +410,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "CodeBuddy", description: "Tencent Cloud's official AI coding assistant (ACP)", distribution: AgentDistribution::Npx { - version: "2.128.0", - package: "@tencent-ai/codebuddy-code@2.128.0", + version: "2.130.0", + package: "@tencent-ai/codebuddy-code@2.130.0", cmd: "codebuddy", args: &["--acp"], env: &[], @@ -424,8 +424,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Kimi Code", description: "Moonshot AI's official CLI coding assistant (ACP)", distribution: AgentDistribution::Npx { - version: "0.29.2", - package: "@moonshot-ai/kimi-code@0.29.2", + version: "0.31.0", + package: "@moonshot-ai/kimi-code@0.31.0", cmd: "kimi", args: &["acp"], env: &[], @@ -481,8 +481,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { // leading `KEY=value` argv and sacp's `parse_env_var` only accepts // `[A-Za-z0-9_]` env names, which npm's `@scope:registry` key is not.) distribution: AgentDistribution::Npx { - version: "0.2.112", - package: "@xai-official/grok@0.2.112", + version: "0.2.114", + package: "@xai-official/grok@0.2.114", cmd: "grok", // Only the ACP subcommand lives here. Grok's ROOT-level launch // flags (`--no-auto-update` always, `--permission-mode ` @@ -493,7 +493,7 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { // args rather than appending after. args: &["agent", "stdio"], env: &[], - // `@xai-official/grok@0.2.112` declares `engines.node: ">=20"`; + // `@xai-official/grok@0.2.114` declares `engines.node: ">=20"`; // surface that in preflight so Node 18 isn't silently accepted. node_required: Some("20.0.0"), }, @@ -704,20 +704,20 @@ mod tests { ); assert_npx_version( AgentType::Cline, - "3.0.46", - "cline@3.0.46", + "3.0.47", + "cline@3.0.47", Some("22.0.0"), ); assert_npx_version( AgentType::CodeBuddy, - "2.128.0", - "@tencent-ai/codebuddy-code@2.128.0", + "2.130.0", + "@tencent-ai/codebuddy-code@2.130.0", Some("22.0.0"), ); assert_npx_version( AgentType::KimiCode, - "0.29.2", - "@moonshot-ai/kimi-code@0.29.2", + "0.31.0", + "@moonshot-ai/kimi-code@0.31.0", Some("22.19.0"), ); assert_npx_version( @@ -729,11 +729,11 @@ mod tests { assert_npx_version(AgentType::Pi, "0.0.32", "pi-acp@0.0.32", Some("22.0.0")); assert_npx_version( AgentType::Grok, - "0.2.112", - "@xai-official/grok@0.2.112", + "0.2.114", + "@xai-official/grok@0.2.114", Some("20.0.0"), ); - assert_binary_version(AgentType::OpenCode, "1.18.8", "/releases/download/v1.18.8/"); + assert_binary_version(AgentType::OpenCode, "1.18.10", "/releases/download/v1.18.10/"); assert_uvx_version( AgentType::Hermes, "0.19.0", diff --git a/src-tauri/src/parsers/grok.rs b/src-tauri/src/parsers/grok.rs index de0bc690a..9986bd0bf 100644 --- a/src-tauri/src/parsers/grok.rs +++ b/src-tauri/src/parsers/grok.rs @@ -23,6 +23,14 @@ use crate::parsers::{ const GROK_TOOL_OUTPUT_CAP: usize = 100_000; const GROK_TOOL_INPUT_CAP: usize = 8_000; +/// Budget for a serialized `TaskOutput` envelope (see `grok_task_output_envelope`). +/// Deliberately below the live path's `MAX_SINGLE_EMIT_BYTES` (64 KiB, see +/// `acp::connection`): the envelope is JSON the frontend parses, and the live +/// emitter truncates from the head with a marker — which would corrupt it. Both +/// paths share this function, so a background command's output is capped here +/// rather than shredded downstream. +const GROK_TASK_OUTPUT_CAP: usize = 48 * 1024; + /// Tool name the parser assigns to grok's native `ask_user_question` (from its /// `_meta["x.ai/tool"].name`). Used to find the ask ToolResults whose answer must /// be recovered from `chat_history.jsonl` (see `inject_grok_ask_answers`). @@ -81,8 +89,10 @@ fn resolve_grok_home_from(grok_home_env: Option, home_dir: Option ParsedUpdates { let mut assistant: Option = None; let mut tool_result_idx: std::collections::HashMap = std::collections::HashMap::new(); - // toolCallIds whose result `task_completed` already finalized. A backgrounded - // command can emit a trailing (stale/cumulative) `tool_call_update` *after* - // its `task_completed` — those must not clobber the authoritative snapshot - // output. toolCallIds are unique within a session, so this is never cleared. - let mut finalized_tools: std::collections::HashSet = - std::collections::HashSet::new(); // Stats for the in-flight turn (tokens/timing/model), applied to the // assistant turn when it is finalized. Reset at each turn boundary. let mut turn_meta = GrokTurnMeta::default(); @@ -386,6 +390,22 @@ fn parse_updates(path: &Path) -> ParsedUpdates { .and_then(Value::as_str) .unwrap_or(""); + // Grok injects its own reminders (a background task finishing, …) as + // `user_message_chunk`s and marks them `_meta.hideFromScrollback` — its + // TUI never shows them. Honor the flag: rendered as a user bubble, such a + // chunk splits one reply into two turns with a raw `` + // block wedged between them. Skipped BEFORE the turn-boundary logic below, + // so a reminder injected mid-turn doesn't cut the open assistant turn + // either. + if kind == "user_message_chunk" + && update + .pointer("/_meta/hideFromScrollback") + .and_then(Value::as_bool) + == Some(true) + { + continue; + } + // Grok's per-turn stats live in the OUTER `params._meta` (token total + // timing) plus `update._meta.modelId`. Accumulate them into `turn_meta` // and apply at the turn boundary. A `user_message_chunk` that opens a NEW @@ -511,32 +531,9 @@ fn parse_updates(path: &Path) -> ParsedUpdates { } "tool_call_update" => { let id = str_field(update, "toolCallId"); - // A trailing update after task_completed must not overwrite the - // authoritative snapshot output. - if !finalized_tools.contains(&id) { - let output = update_tool_output(update); - let failed = update.get("status").and_then(Value::as_str) == Some("failed"); - apply_tool_result(assistant.as_mut(), &tool_result_idx, &id, output, failed); - } - } - "task_completed" => { - let snap = update.get("task_snapshot"); - let id = snap.map(|s| str_field(s, "task_id")).unwrap_or_default(); - let output = snap - .and_then(|s| s.get("output")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(|s| truncate_str(s, GROK_TOOL_OUTPUT_CAP)); - let failed = snap - .and_then(|s| s.get("exit_code")) - .and_then(Value::as_i64) - .is_some_and(|code| code != 0); - // task_completed is authoritative for a backgrounded command; - // finalize the id so a trailing tool_call_update can't clobber it. + let output = update_tool_output(update); + let failed = update.get("status").and_then(Value::as_str) == Some("failed"); apply_tool_result(assistant.as_mut(), &tool_result_idx, &id, output, failed); - if !id.is_empty() { - finalized_tools.insert(id); - } } "turn_completed" => { if let Some(mut turn) = assistant.take() { @@ -590,8 +587,21 @@ fn parse_updates(path: &Path) -> ParsedUpdates { images: Vec::new(), }); } - // task_backgrounded / plan / other extension updates carry no - // distinct rendered content beyond what the tool stream already has. + // `task_backgrounded` / `task_completed` / plan / other extension + // updates carry no distinct rendered content beyond what the tool + // stream already has. + // + // In particular `task_completed`'s snapshot is deliberately NOT + // applied to the launching tool call: Grok reports that CALL as + // `completed` on the wire (it did start the task), the live path + // never receives this ext notification at all, and the task's real + // outcome — command, exit code, output — renders from the + // `get_command_or_subagent_output` polls (see + // `grok_task_output_envelope`). Writing the snapshot here would make + // history contradict live for the same conversation. + // + // Known gap: a task the model never polls has its output ONLY in + // this snapshot, so neither path surfaces it. _ => {} } } @@ -872,9 +882,10 @@ fn grok_mcp_output_text(raw_output: &Value) -> Option { /// Extract the tool output text from a `tool_call_update`. Prefers the ACP /// `content[]` array (`{type:"content", content:{type:"text", text}}`), then -/// `rawOutput.output_for_prompt` (Bash/terminal), then an MCP `rawOutput`'s -/// `output` text (`use_tool`). All are cumulative, so the last update per call -/// carries the full output. +/// `rawOutput.output_for_prompt` (Bash/terminal), then a `TaskOutput` envelope +/// (background-task polls — see `grok_task_output_envelope`), then an MCP +/// `rawOutput`'s `output` text (`use_tool`). All are cumulative, so the last +/// update per call carries the full output. fn update_tool_output(update: &Value) -> Option { if let Some(items) = update.get("content").and_then(Value::as_array) { let mut buf = String::new(); @@ -902,6 +913,9 @@ fn update_tool_output(update: &Value) -> Option { { return Some(truncate_str(text, GROK_TOOL_OUTPUT_CAP)); } + if let Some(envelope) = update.get("rawOutput").and_then(grok_task_output_envelope) { + return Some(envelope); + } update .get("rawOutput") .and_then(grok_mcp_output_text) @@ -931,16 +945,49 @@ fn grok_mcp_input_preview(input: &Value) -> Option { if input.is_null() { return None; } - let mut per_string = GROK_TOOL_INPUT_CAP; + cap_json_to_budget(input, GROK_TOOL_INPUT_CAP) +} + +/// Serialize `value` as JSON that stays VALID within `budget` bytes: cap every +/// string value, halving the per-string cap until the WHOLE serialized form +/// fits. Checking the actual serialized length each pass is what bounds every +/// bloat vector (many strings, long arrays, JSON/UTF-8 escaping that expands +/// bytes) — a single per-field cap could not. Converges in O(log budget) passes; +/// an already-small value returns on the first pass unchanged. +fn cap_json_to_budget(value: &Value, budget: usize) -> Option { + let mut per_string = budget; loop { - let serialized = serde_json::to_string(&cap_json_string_values(input, per_string)).ok()?; - if serialized.len() <= GROK_TOOL_INPUT_CAP || per_string == 0 { + let serialized = serde_json::to_string(&cap_json_string_values(value, per_string)).ok()?; + if serialized.len() <= budget || per_string == 0 { return Some(serialized); } per_string /= 2; } } +/// Serialize a Grok `TaskOutput` `rawOutput` — the result of a +/// `get_command_or_subagent_output` poll — for the frontend, which parses it +/// into a background-task card (`@/lib/background-task`). Returns `None` for +/// every other `rawOutput`, so the caller falls through to its normal paths. +/// +/// The WHOLE envelope is passed through verbatim (bounded by +/// [`GROK_TASK_OUTPUT_CAP`]): its `type` discriminator is what lets the frontend +/// claim it without hijacking other JSON tool output, and passing it whole means +/// the variants Grok can put beside it (`Result`, `MultiResult`, `TaskNotFound`) +/// need no per-variant handling here. Without this the readable output — the +/// command, exit code and shell text all live under `Result` — is dropped +/// entirely: `content[]` is absent on these updates, and the `output_for_prompt` +/// / MCP paths don't match. +/// +/// Shared with the live path (`acp::connection::grok_live_tool_output`) so both +/// hand the frontend a byte-identical string. +pub(crate) fn grok_task_output_envelope(raw_output: &Value) -> Option { + if raw_output.get("type").and_then(Value::as_str) != Some("TaskOutput") { + return None; + } + cap_json_to_budget(raw_output, GROK_TASK_OUTPUT_CAP) +} + /// Truncate every string value in a JSON value to `cap` chars, preserving /// structure so the result re-serializes to valid JSON. fn cap_json_string_values(value: &Value, cap: usize) -> Value { @@ -1174,11 +1221,16 @@ mod tests { r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"turn_completed","prompt_id":"p0","stop_reason":"end_turn"}},"timestamp":1783584024}"#, "\n", r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"执行 pnpm build"},"_meta":{"promptIndex":1}}},"timestamp":1783584029}"#, "\n", r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"正在执行"}}},"timestamp":1783584029}"#, "\n", - r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"pnpm build"},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}}},"timestamp":1783584029}"#, "\n", - r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"partial output"}}]}},"timestamp":1783584033}"#, "\n", - r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"call-1","output":"build ok","exit_code":0}}},"timestamp":1783584122}"#, "\n", - // Trailing (stale) update AFTER task_completed — must NOT clobber "build ok". - r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"STALE trailing output"}}]}},"timestamp":1783584123}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"pnpm build","background":true},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}}},"timestamp":1783584029}"#, "\n", + // The only event pairing the task id with the launching tool call. + r#"{"method":"_x.ai/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call-1","task_id":"term_x","command":"pnpm build"}},"timestamp":1783584029}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] pnpm build (term_x)","content":[{"type":"content","content":{"type":"text","text":"Background task term_x started"}}]}},"timestamp":1783584033}"#, "\n", + // Snapshot keyed by `task_id` — deliberately ignored, so the launch call + // stays exactly as the wire (and the live path) reports it. + r#"{"method":"_x.ai/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"term_x","command":"/bin/bash -lc 'pnpm build'","output":"boom","exit_code":1}}},"timestamp":1783584122}"#, "\n", + // The model polls the task; its whole result lives in `rawOutput`. + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"get_command_or_subagent_output","rawInput":{"task_ids":["term_x"],"timeout_ms":15000},"_meta":{"x.ai/tool":{"name":"get_command_or_subagent_output","kind":"background_task_action"}}}},"timestamp":1783584123}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"/bin/bash -lc 'pnpm build' (term_x)","rawOutput":{"type":"TaskOutput","Result":{"task_id":"term_x","command":"/bin/bash -lc 'pnpm build'","status":"failed","exit_code":1,"output":"boom"}}}},"timestamp":1783584124}"#, "\n", r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"turn_completed","prompt_id":"p1","stop_reason":"end_turn"}},"timestamp":1783584129}"#, "\n", ); @@ -1227,8 +1279,11 @@ mod tests { assert!( matches!(tool_use, ContentBlock::ToolUse { tool_name, .. } if tool_name == "run_terminal_command") ); - // task_completed output ("build ok") is authoritative over the streamed - // "partial output", and exit_code 0 → not an error. + // The launch keeps its own "started" text and its wire status: Grok + // reports the CALL as completed (it did start the task), and the failing + // `task_completed` snapshot is not applied — otherwise history would + // contradict the live path, which never sees that ext notification. The + // task's failure surfaces on the poll below. let tool_result = last .blocks .iter() @@ -1237,10 +1292,88 @@ mod tests { assert!(matches!( tool_result, ContentBlock::ToolResult { output_preview, is_error, .. } - if output_preview.as_deref() == Some("build ok") && !*is_error + if output_preview.as_deref() == Some("Background task term_x started") && !*is_error )); } + /// A `get_command_or_subagent_output` poll carries its whole result in + /// `rawOutput` (no `content[]`, no `output_for_prompt`), which used to be + /// dropped — leaving the card empty. It must reach the frontend verbatim so + /// the background-task card can render command/status/exit code/output. + #[test] + fn background_task_poll_surfaces_task_output_envelope() { + let (_tmp, sessions) = fixture(SUMMARY, UPDATES); + let detail = GrokParser::with_base_dir(sessions) + .get_conversation("019f45e3-e1ef-7690-a29f-fe2554382b49") + .unwrap(); + let blocks = &detail.turns[3].blocks; + + let poll = blocks + .iter() + .filter_map(|b| match b { + ContentBlock::ToolUse { + tool_name, + tool_use_id, + .. + } => Some((tool_name, tool_use_id)), + _ => None, + }) + .find(|(name, _)| name.as_str() == "get_command_or_subagent_output") + .expect("poll tool use"); + assert_eq!(poll.1.as_deref(), Some("call-2")); + + let output = blocks + .iter() + .find_map(|b| match b { + ContentBlock::ToolResult { + tool_use_id, + output_preview, + .. + } if tool_use_id.as_deref() == Some("call-2") => output_preview.clone(), + _ => None, + }) + .expect("poll ToolResult output"); + let env: Value = serde_json::from_str(&output).expect("envelope is valid JSON"); + assert_eq!(env["type"], "TaskOutput"); + assert_eq!(env["Result"]["exit_code"], 1); + assert_eq!(env["Result"]["status"], "failed"); + assert_eq!(env["Result"]["output"], "boom"); + } + + /// Grok injects reminders as `user_message_chunk`s flagged + /// `_meta.hideFromScrollback`. Rendering them as user bubbles split one reply + /// into two turns with a raw `` wedged between. + #[test] + fn hidden_user_chunk_does_not_split_the_reply() { + let updates = concat!( + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"启动服务"},"_meta":{"modelId":"grok-4.5","promptIndex":0}}},"timestamp":1783584019}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"已启动"}}},"timestamp":1783584020}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"turn_completed","prompt_id":"p0","stop_reason":"end_turn"}},"timestamp":1783584021}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"\nBackground task \"term_x\" completed (exit code: 1).\n"},"_meta":{"modelId":"grok-4.5","promptIndex":1,"hideFromScrollback":true}}},"timestamp":1783584022}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"那次失败可以忽略"}}},"timestamp":1783584023}"#, "\n", + r#"{"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"turn_completed","prompt_id":"p1","stop_reason":"end_turn"}},"timestamp":1783584024}"#, "\n", + ); + let (_tmp, sessions) = fixture(SUMMARY, updates); + let detail = GrokParser::with_base_dir(sessions) + .get_conversation("019f45e3-e1ef-7690-a29f-fe2554382b49") + .unwrap(); + + // One real prompt + the two assistant replies; no reminder bubble. + assert_eq!( + detail + .turns + .iter() + .filter(|t| matches!(t.role, TurnRole::User)) + .count(), + 1 + ); + assert!(!detail.turns.iter().any(|t| t + .blocks + .iter() + .any(|b| matches!(b, ContentBlock::Text { text } if text.contains("system-reminder"))))); + assert!(matches!(&detail.turns[2].blocks[0], ContentBlock::Text { text } if text == "那次失败可以忽略")); + } + #[test] fn history_renders_auto_compaction_as_context_compaction_tool() { // Grok's auto-compaction lands on the namespaced `_x.ai/session/update` diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 43c9911ea..d651a4b54 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.22.1", + "version": "0.22.2", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev", diff --git a/src/components/message/background-task-card.test.tsx b/src/components/message/background-task-card.test.tsx index 1ac215717..9340234fb 100644 --- a/src/components/message/background-task-card.test.tsx +++ b/src/components/message/background-task-card.test.tsx @@ -68,4 +68,33 @@ describe("BackgroundTaskCard", () => { renderCard([poll({ output: null, state: "input-available" })]) expect(screen.getByText("Running")).toBeInTheDocument() }) + + it("renders a Grok TaskOutput poll with its command, badge and output", () => { + // Grok reports the same lifecycle as a JSON envelope; the row is titled by + // the command it polled (Claude's polls carry no command, hence the id + // fallback in the test above). + renderCard([ + poll({ + toolName: "get_command_or_subagent_output", + input: JSON.stringify({ task_ids: ["term_b0d"], timeout_ms: 15000 }), + output: JSON.stringify({ + type: "TaskOutput", + Result: { + task_id: "term_b0d", + command: "/bin/bash -lc 'pnpm dev -- --port 3001'", + status: "failed", + exit_code: 1, + output: "INVALID DIRECTORY MARKER", + }, + }), + }), + ]) + expect( + screen.getByText("/bin/bash -lc 'pnpm dev -- --port 3001'") + ).toBeInTheDocument() + expect(screen.getByText("Failed")).toBeInTheDocument() + expect(screen.getByText("exit 1")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("INVALID DIRECTORY MARKER")).toBeInTheDocument() + }) }) diff --git a/src/lib/background-task.test.ts b/src/lib/background-task.test.ts index 78501d627..bdfbfdc36 100644 --- a/src/lib/background-task.test.ts +++ b/src/lib/background-task.test.ts @@ -5,6 +5,7 @@ import { isBackgroundTaskToolCall, parseBackgroundLaunch, parseBackgroundTaskEnvelope, + parseBackgroundTaskEnvelopes, } from "@/lib/background-task" import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" @@ -53,6 +54,39 @@ const STOP_JSON = JSON.stringify({ const LAUNCH = "Command running in background with ID: be7lh91re. Output is being written to: /private/tmp/x/tasks/be7lh91re.output. You will be notified when it completes." +// Grok's shapes, captured from a real session (~/.grok/…/019fb314…): the launch +// acknowledgement, and the poll's JSON `TaskOutput` envelope. +const GROK_LAUNCH = + "Background task term_b0d9512484964551a5bac4f82a805ae2 started" + +const GROK_FAILED = JSON.stringify({ + type: "TaskOutput", + Result: { + task_id: "term_b0d", + command: "/bin/bash -lc 'pnpm dev -- --port 3001'", + status: "failed", + exit_code: 1, + started: "2026-07-30T12:52:48Z", + ended: "2026-07-30T12:52:49Z", + duration_secs: 0.787718, + output: + "$ next dev --turbopack -- --port 3001\nInvalid project directory provided, no such directory: /Users/me/proj/--port\n", + truncated: false, + }, +}) + +const GROK_RUNNING = JSON.stringify({ + type: "TaskOutput", + Result: { + task_id: "term_273", + command: "/bin/bash -lc 'pnpm exec next dev -p 3001'", + status: "running", + exit_code: null, + output: + "▲ Next.js 16.0.0\n- Local: http://localhost:3001\n✓ Ready in 1.2s\n", + }, +}) + function poll(over: Partial = {}): AdaptedToolCallPart { return { type: "tool-call", @@ -69,6 +103,18 @@ function poll(over: Partial = {}): AdaptedToolCallPart { } } +/** A Grok `get_command_or_subagent_output` poll of the failed dev-server task. */ +function grokPoll( + over: Partial = {} +): AdaptedToolCallPart { + return poll({ + toolName: "get_command_or_subagent_output", + input: JSON.stringify({ task_ids: ["term_b0d"], timeout_ms: 15000 }), + output: GROK_FAILED, + ...over, + }) +} + describe("parseBackgroundTaskEnvelope", () => { it("parses a completed poll envelope", () => { const env = parseBackgroundTaskEnvelope(COMPLETED) @@ -123,12 +169,161 @@ describe("parseBackgroundLaunch", () => { it("extracts the task id from a background launch result", () => { expect(parseBackgroundLaunch(LAUNCH)).toEqual({ taskId: "be7lh91re" }) }) + it("extracts the task id from Grok's launch acknowledgement", () => { + expect(parseBackgroundLaunch(GROK_LAUNCH)).toEqual({ + taskId: "term_b0d9512484964551a5bac4f82a805ae2", + }) + }) it("returns null for non-launch text", () => { expect(parseBackgroundLaunch(COMPLETED)).toBeNull() expect(parseBackgroundLaunch(null)).toBeNull() }) }) +describe("Grok TaskOutput envelopes", () => { + it("parses a failed poll (command, exit code, output)", () => { + const env = parseBackgroundTaskEnvelope(GROK_FAILED) + expect(env).not.toBeNull() + expect(env!.kind).toBe("poll") + expect(env!.taskId).toBe("term_b0d") + expect(env!.command).toBe("/bin/bash -lc 'pnpm dev -- --port 3001'") + expect(env!.status).toBe("failed") + expect(env!.exitCode).toBe(1) + expect(env!.output).toContain("Invalid project directory") + }) + + it("parses a still-running poll", () => { + const env = parseBackgroundTaskEnvelope(GROK_RUNNING) + expect(env!.status).toBe("running") + expect(env!.exitCode).toBeNull() + expect(env!.output).toContain("Ready in") + }) + + it("expands a MultiResult into one envelope per task", () => { + const envs = parseBackgroundTaskEnvelopes( + JSON.stringify({ + type: "TaskOutput", + MultiResult: { + result_count: 2, + results: [ + { task_id: "term_a", status: "completed", exit_code: 0 }, + { task_id: "term_b", status: "running" }, + ], + }, + }) + ) + expect(envs.map((e) => e.taskId)).toEqual(["term_a", "term_b"]) + }) + + it("maps a killed task to the stopped kind", () => { + const env = parseBackgroundTaskEnvelope( + JSON.stringify({ + type: "TaskOutput", + Result: { task_id: "term_a", status: "killed", command: "sleep 900" }, + }) + ) + expect(env!.kind).toBe("stop") + }) + + it("ignores non-TaskOutput JSON, TaskNotFound, and truncated envelopes", () => { + // A sub-agent poll of the SAME tool: different payload → generic rendering. + expect( + parseBackgroundTaskEnvelope( + JSON.stringify({ + type: "SubagentCompleted", + Result: { subagent_id: "s1", turns: 3 }, + }) + ) + ).toBeNull() + expect( + parseBackgroundTaskEnvelope( + JSON.stringify({ type: "TaskOutput", TaskNotFound: { task_id: "x" } }) + ) + ).toBeNull() + expect( + parseBackgroundTaskEnvelope(GROK_FAILED.slice(0, GROK_FAILED.length - 20)) + ).toBeNull() + }) + + it("routes Grok polls into the background lane, sub-agent polls out of it", () => { + // Settled poll → matched by its envelope. + expect(isBackgroundTaskToolCall(grokPoll())).toBe(true) + // In-flight poll → matched by `{task_ids, timeout_ms}` before any output. + expect( + isBackgroundTaskToolCall( + grokPoll({ output: null, state: "input-available" }) + ) + ).toBe(true) + // Same tool polling a sub-agent: its result is a different payload, so the + // call leaves the lane as soon as it settles. + expect( + isBackgroundTaskToolCall( + grokPoll({ + output: JSON.stringify({ + type: "SubagentCompleted", + Result: { subagent_id: "s1", turns: 3 }, + }), + }) + ) + ).toBe(false) + // …and it leaves the lane even when the sub-agent payload never reaches + // `output` at all: no backend path serializes a `SubagentCompleted` + // rawOutput, so the settled poll arrives with an empty result. Keying the + // input-shape claim on "still in flight" (not on "has foreign output") is + // what stops it from rendering as a permanently running background row. + expect( + isBackgroundTaskToolCall( + grokPoll({ output: null, state: "output-available" }) + ) + ).toBe(false) + expect( + isBackgroundTaskToolCall( + grokPoll({ output: null, state: "output-available", errorText: "" }) + ) + ).toBe(false) + }) + + it("keeps a live poll whose status has not settled yet", () => { + // A promoted orphan: re-adapted with state output-available at COMPLETE_TURN + // while its forwarded ACP status is still in_progress. + expect( + isBackgroundTaskToolCall( + grokPoll({ + output: null, + state: "output-available", + toolStatus: "in_progress", + }) + ) + ).toBe(true) + }) + + it("builds a failed row carrying the command and exit code", () => { + const rows = buildBackgroundTaskRows([ + grokPoll({ toolCallId: "c1", output: null, state: "input-available" }), + grokPoll({ toolCallId: "c2" }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].taskId).toBe("term_b0d") + expect(rows[0].badge).toBe("failed") + expect(rows[0].exitCode).toBe(1) + expect(rows[0].command).toBe("/bin/bash -lc 'pnpm dev -- --port 3001'") + expect(rows[0].output).toContain("Invalid project directory") + expect(rows[0].pollCount).toBe(2) + }) + + it("marks a failure with no exit code as failed, not completed", () => { + const rows = buildBackgroundTaskRows([ + grokPoll({ + output: JSON.stringify({ + type: "TaskOutput", + Result: { task_id: "term_z", status: "failed", command: "x" }, + }), + }), + ]) + expect(rows[0].badge).toBe("failed") + }) +}) + describe("isBackgroundTaskToolCall", () => { it("matches by raw tool name (historical path)", () => { expect( diff --git a/src/lib/background-task.ts b/src/lib/background-task.ts index aa81251ff..ebdde8a56 100644 --- a/src/lib/background-task.ts +++ b/src/lib/background-task.ts @@ -1,6 +1,8 @@ /** - * Shared parsing + detection helpers for Claude Code's built-in background-task - * tools (`Bash(run_in_background)` launch → `TaskOutput` polls → `TaskStop`). + * Shared parsing + detection helpers for built-in background-task tools: + * Claude Code's (`Bash(run_in_background)` launch → `TaskOutput` polls → + * `TaskStop`) and Grok's (`run_terminal_command(background)` launch → + * `get_command_or_subagent_output` polls). * * Claude Code starts a background shell with `Bash(run_in_background: true)`, * whose result is a launch line carrying the task id ("Command running in @@ -16,12 +18,21 @@ * stop → {"message":"Successfully stopped task: … ()", * "task_id":…, "task_type":…, "command":…} * + * Grok reports the same lifecycle as a JSON envelope instead — its poll's whole + * result sits in the ACP `rawOutput`, which both paths hand over verbatim + * (`parsers/grok.rs::grok_task_output_envelope`, shared with the live path): + * + * poll → {"type":"TaskOutput","Result":{task_id, command, status, + * exit_code, output, …}} (also `MultiResult` / `TaskNotFound`) + * launch→ "Background task started" + * * The same task is polled repeatedly (first timeout/running, then * success/completed), so the renderer collapses consecutive polls of one task * id into a single lifecycle card — mirroring the delegation-status group * (`@/lib/delegation-status`). Codeg owns only the rendering: the backend - * (`parsers/claude.rs`) passes the tool-result text through verbatim, so all - * parsing lives here (same convention as `delegation-status.ts`). + * (`parsers/claude.rs`, `parsers/grok.rs`) passes the tool-result text through + * verbatim, so all parsing lives here (same convention as + * `delegation-status.ts`). */ import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" @@ -125,26 +136,130 @@ function parseStopEnvelope(text: string): BackgroundTaskEnvelope | null { } } +/** Statuses Grok reports for a task that was ended rather than allowed to + * finish — mapped to the `stop` kind so the row reads "stopped". */ +const GROK_STOPPED_STATUSES: ReadonlySet = new Set([ + "killed", + "stopped", + "cancelled", + "canceled", +]) + +/** Map one Grok `TaskOutputResult` onto the shared envelope. `status` is passed + * through verbatim (lower-cased) — `deriveBackgroundBadge` reads it. */ +function grokResultToEnvelope( + result: Record +): BackgroundTaskEnvelope | null { + const taskId = typeof result.task_id === "string" ? result.task_id : null + const command = typeof result.command === "string" ? result.command : null + const status = + typeof result.status === "string" + ? result.status.trim().toLowerCase() + : null + // Require something identifying so a stray `{type:"TaskOutput"}` isn't read + // as a real (blank) task row. + if (taskId == null && command == null && status == null) return null + const output = typeof result.output === "string" ? result.output : null + const exitCode = + typeof result.exit_code === "number" && Number.isFinite(result.exit_code) + ? result.exit_code + : null + return { + kind: status != null && GROK_STOPPED_STATUSES.has(status) ? "stop" : "poll", + retrievalStatus: null, + taskId, + taskType: null, + status, + exitCode, + output, + command, + message: null, + } +} + +/** + * Collect the result objects out of a Grok `TaskOutput` envelope. `Result` is + * the single-task variant (the only one captured from a real session); + * `MultiResult` wraps several, and `TaskNotFound` carries none. The MultiResult + * walk is deliberately shape-tolerant — it takes the first array of objects it + * finds — because its field names aren't pinned by any capture we have. + */ +function grokResultObjects( + envelope: Record +): Record[] { + const out: Record[] = [] + const push = (value: unknown) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + out.push(value as Record) + } + } + push(envelope.Result) + const multi = envelope.MultiResult + if (multi && typeof multi === "object" && !Array.isArray(multi)) { + for (const value of Object.values(multi as Record)) { + if (Array.isArray(value)) { + value.forEach(push) + break + } + } + } + return out +} + +/** Parse a Grok `get_command_or_subagent_output` result. Strict on the `type` + * discriminator so other JSON tool output is never hijacked. */ +function parseGrokTaskOutputEnvelopes(text: string): BackgroundTaskEnvelope[] { + if (!text.startsWith("{")) return [] + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // A truncated envelope (an enormous log) degrades to the generic card + // rather than rendering a half-parsed task. + return [] + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [] + const envelope = parsed as Record + if (envelope.type !== "TaskOutput") return [] + return grokResultObjects(envelope) + .map(grokResultToEnvelope) + .filter((e): e is BackgroundTaskEnvelope => e !== null) +} + +/** Every task reported by one background-task tool result: a single envelope for + * Claude Code's poll/stop shapes, and one per task for Grok's `TaskOutput` + * (whose `MultiResult` variant can cover several). Empty when the text is + * neither (callers fall back to generic rendering). */ +export function parseBackgroundTaskEnvelopes( + text: string | null | undefined +): BackgroundTaskEnvelope[] { + const raw = text?.trim() + if (!raw) return [] + const single = parsePollEnvelope(raw) ?? parseStopEnvelope(raw) + if (single) return [single] + return parseGrokTaskOutputEnvelopes(raw) +} + /** Parse a background-task tool result into its structured envelope, or `null` - * when the text is neither a `TaskOutput` poll nor a `TaskStop` ack (callers - * fall back to generic rendering). */ + * when the text is none of the recognized shapes (callers fall back to generic + * rendering). The FIRST task when a result covers several. */ export function parseBackgroundTaskEnvelope( text: string | null | undefined ): BackgroundTaskEnvelope | null { - const raw = text?.trim() - if (!raw) return null - return parsePollEnvelope(raw) ?? parseStopEnvelope(raw) + return parseBackgroundTaskEnvelopes(text)[0] ?? null } const LAUNCH_RE = /Command running in background with ID:\s*([A-Za-z0-9_-]+)/i +/** Grok's `run_terminal_command(background: true)` acknowledgement. */ +const GROK_LAUNCH_RE = /Background task\s+([A-Za-z0-9_-]+)\s+started/i -/** Recognize the `Bash(run_in_background: true)` launch result and pull the task - * id. Lets the command card flag itself as a background launch. */ +/** Recognize a background launch result and pull the task id. Lets the command + * card flag itself as a background launch. */ export function parseBackgroundLaunch( text: string | null | undefined ): { taskId: string } | null { if (!text) return null - const match = text.match(LAUNCH_RE) + const match = text.match(LAUNCH_RE) ?? text.match(GROK_LAUNCH_RE) return match ? { taskId: match[1] } : null } @@ -169,27 +284,42 @@ function parseInputObject( } } -/** A `TaskOutput` poll's input shape: `{task_id, block?, timeout?}`. The - * `block`/`timeout` requirement is what distinguishes it from `cancel_delegation` - * / `TaskStop` (which carry a bare `{task_id}`); `subagent_type` is excluded so - * real sub-agent `Agent`/`Task` calls never match. Catches the live in-flight - * poll before its output (and thus its envelope) has arrived. */ +/** A poll's input shape — Claude Code's `{task_id, block?, timeout?}` or Grok's + * `{task_ids: [...], timeout_ms}`. The `block`/`timeout`/`timeout_ms` + * requirement is what distinguishes them from `cancel_delegation` / `TaskStop` + * (bare `{task_id}`) and `get_delegation_status` (`{task_ids, wait_ms}`); + * `subagent_type` is excluded so real sub-agent `Agent`/`Task` calls never + * match. Catches the live in-flight poll before its output (and thus its + * envelope) has arrived. */ function inputIsBackgroundPoll(input: string | null | undefined): boolean { const obj = parseInputObject(input) if (!obj) return false - if (typeof obj.task_id !== "string" || obj.task_id.length === 0) return false if ("subagent_type" in obj) return false - return "block" in obj || "timeout" in obj + if (typeof obj.task_id === "string" && obj.task_id.length > 0) { + return "block" in obj || "timeout" in obj + } + return ( + Array.isArray(obj.task_ids) && + obj.task_ids.length > 0 && + obj.task_ids.every((id) => typeof id === "string") && + "timeout_ms" in obj + ) } /** - * Whether a tool-call part is a Claude Code background-task poll/stop. True when - * ANY holds: the raw tool name is `TaskOutput`/`TaskStop` (historical path); the - * output parses as a background-task envelope (covers the live `task` alias and - * any naming); or the input is a `TaskOutput` poll shape (covers the live - * in-flight poll). Deliberately does NOT match real sub-agent `Agent` calls - * (`subagent_type`), `get_delegation_status` (`task_ids` + JSON), or - * `cancel_delegation` (bare `{task_id}`). + * Whether a tool-call part is a background-task poll/stop. True when ANY holds: + * the raw tool name is `TaskOutput`/`TaskStop` (Claude Code's historical path); + * the output parses as a background-task envelope (covers Claude's live `task` + * alias, Grok's `TaskOutput` JSON, and any naming); or the input is a poll shape + * (covers the live in-flight poll). Deliberately does NOT match real sub-agent + * `Agent` calls (`subagent_type`), `get_delegation_status` (`task_ids` + + * `wait_ms`), or `cancel_delegation` (bare `{task_id}`). + * + * Grok's `get_command_or_subagent_output` is intentionally absent from + * `BACKGROUND_TASK_NAMES`: the same tool also polls sub-agents, whose result is + * a completely different `SubagentCompleted` payload. Matching on the envelope + * (plus the in-flight input shape) means a sub-agent poll leaves this lane the + * moment its result lands, keeping its generic rendering. */ export function isBackgroundTaskToolCall(part: AdaptedToolCallPart): boolean { if (BACKGROUND_TASK_NAMES.has(part.toolName.trim().toLowerCase())) return true @@ -198,7 +328,17 @@ export function isBackgroundTaskToolCall(part: AdaptedToolCallPart): boolean { ) { return true } - return inputIsBackgroundPoll(part.input) + if (!inputIsBackgroundPoll(part.input)) return false + // The input shape alone cannot tell a background-command poll from a sub-agent + // poll — Grok's `get_command_or_subagent_output` does both with identical + // args. So the input shape only claims a call that is STILL IN FLIGHT (its + // documented purpose: render the live card in the lane it will settle into). + // Once settled, the envelope check above is the sole authority, so a sub-agent + // result — `SubagentCompleted`, which no backend path serializes into + // `part.output` at all — falls back to generic rendering instead of showing a + // permanently "running" background row. Claude Code's own polls are unaffected: + // they are claimed by name. + return isUnsettledToolCall(part) } export type BackgroundTaskBadge = "running" | "completed" | "failed" | "stopped" @@ -221,9 +361,17 @@ export interface BackgroundTaskRow { function inputTaskId(input: string | null | undefined): string | null { const obj = parseInputObject(input) - return obj && typeof obj.task_id === "string" && obj.task_id.length > 0 - ? obj.task_id - : null + if (!obj) return null + if (typeof obj.task_id === "string" && obj.task_id.length > 0) { + return obj.task_id + } + // Grok polls by list; a single-task poll still identifies its row, which is + // what lets an in-flight poll merge into the same row as its settled sibling. + if (Array.isArray(obj.task_ids) && obj.task_ids.length === 1) { + const [id] = obj.task_ids + if (typeof id === "string" && id.length > 0) return id + } + return null } function isInFlightState(part: AdaptedToolCallPart): boolean { @@ -235,6 +383,10 @@ function deriveBackgroundBadge( part: AdaptedToolCallPart ): BackgroundTaskBadge { if (envelope?.kind === "stop") return "stopped" + // Grok reports the outcome in `status` itself; Claude Code only ever says + // running/completed, so this is inert for it. Checked before `completed` so a + // failure with no exit code (a spawn error) doesn't read as success. + if (envelope?.status === "failed") return "failed" if (envelope?.status === "completed") { return envelope.exitCode != null && envelope.exitCode !== 0 ? "failed" @@ -271,10 +423,10 @@ export function buildBackgroundTaskRows( string, { taskId: string | null; entries: ParsedBackgroundPoll[] } >() - for (const poll of polls) { - const envelope = parseBackgroundTaskEnvelope( - poll.output ?? poll.errorText ?? null - ) + const record = ( + poll: AdaptedToolCallPart, + envelope: BackgroundTaskEnvelope | null + ) => { const taskId = envelope?.taskId ?? inputTaskId(poll.input) ?? null // Drop an unsettled poll that carries no identity AND no output yet — a live // `TaskOutput` whose `task_id` hasn't streamed onto the wire. claude-agent-acp @@ -289,7 +441,7 @@ export function buildBackgroundTaskRows( // settled — which would otherwise re-stack after the turn completes. // Mirrors `buildDelegationTaskRows`. if (taskId == null && envelope == null && isUnsettledToolCall(poll)) { - continue + return } const key = taskId ?? `__bg__:${poll.toolCallId}` let entry = byKey.get(key) @@ -300,6 +452,20 @@ export function buildBackgroundTaskRows( } entry.entries.push({ poll, envelope }) } + + for (const poll of polls) { + // One poll can report several tasks (Grok's `MultiResult`), so each + // envelope is attributed to its own row. + const envelopes = parseBackgroundTaskEnvelopes( + poll.output ?? poll.errorText ?? null + ) + if (envelopes.length === 0) { + record(poll, null) + continue + } + for (const envelope of envelopes) record(poll, envelope) + } + return order.map((key) => { const entry = byKey.get(key)! const latest = entry.entries[entry.entries.length - 1] diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index fbd9ce6a3..098892c87 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -598,6 +598,100 @@ describe("inferLiveToolName Grok plan-mode via x.ai/tool.kind", () => { }) }) +describe("inferLiveToolName Grok identity via x.ai/tool.name", () => { + // The three frames of ONE background-task poll, captured from a real session + // (~/.grok/…/019fb314…). Grok rewrites `title` on every update, so the title + // fallback named this single call three different things — the last one + // colliding with the bash card it was polling. + const meta = { + "x.ai/tool": { + version: 1, + name: "get_command_or_subagent_output", + kind: "background_task_action", + namespace: "grok_build", + label: "Background Task", + read_only: true, + }, + } + + it("keeps one identity across the whole mutating lifecycle", () => { + const announced = inferLiveToolName({ + title: "get_command_or_subagent_output", + kind: null, + rawInput: JSON.stringify({ task_ids: ["term_b0d"], timeout_ms: 15000 }), + meta, + }) + const inFlight = inferLiveToolName({ + title: "Get task output: term_b0d9512484964551a5bac4f82a805ae2", + kind: "other", + rawInput: JSON.stringify({ + variant: "TaskOutput", + task_ids: ["term_b0d"], + timeout_ms: 15000, + }), + meta, + }) + // Completed: the title becomes the polled command — this used to collapse + // to "bash" and fold into the launching command's tool group. + const completed = inferLiveToolName({ + title: "/bin/bash -lc 'pnpm dev -- --port 3001' (term_b0d)", + kind: "other", + rawInput: JSON.stringify({ + variant: "TaskOutput", + task_ids: ["term_b0d"], + timeout_ms: 15000, + }), + meta, + }) + + expect(announced).toBe("get_command_or_subagent_output") + expect(inFlight).toBe(announced) + expect(completed).toBe(announced) + }) + + it("lets the input shape keep priority over the meta name", () => { + // Grok's edit tool: the meta name (`search_replace`) has no card of its own, + // while the input shape routes it to the diff card. Input wins. + expect( + inferLiveToolName({ + title: "Edit `/tmp/a.ts`", + kind: "edit", + rawInput: JSON.stringify({ + file_path: "/tmp/a.ts", + old_string: "a", + new_string: "b", + }), + meta: { "x.ai/tool": { name: "search_replace", kind: "edit" } }, + }) + ).toBe("edit") + }) + + it("resolves an arg-less frame from the meta name", () => { + // Before rawInput streams in there is nothing else to go on. + expect( + inferLiveToolName({ + title: "read_file", + kind: null, + rawInput: null, + meta: { "x.ai/tool": { name: "read_file", kind: "read" } }, + }) + ).toBe("read") + }) + + it("ignores the generic `use_tool` MCP envelope", () => { + // The backend unwraps the envelope into the title; the envelope name would + // send every MCP call to the generic tool card instead. + expect( + inferLiveToolName({ + title: "codeg-mcp__delegate_to_agent", + kind: "other", + rawInput: JSON.stringify({ agent_type: "codex", task: "run build" }), + meta: { "x.ai/tool": { name: "use_tool", kind: "use_tool" } }, + }) + ).toBe("delegate_to_agent") + }) +}) + describe("normalizeToolName codex command-action titles", () => { it("resolves search command actions to grep", () => { // codex-acp announces a search-classified shell command with NO rawInput and diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index d896d581f..758075d1e 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -548,6 +548,21 @@ export function inferLiveToolName(params: { // heuristic rewrites `memory_recall` to `memory_re`. if (metaToolName) return metaToolName.toLowerCase() + // Grok stamps the authoritative tool name in `_meta["x.ai/tool"].name` while + // its `title` MUTATES across the lifecycle. A background-task poll is the + // worst case: `get_command_or_subagent_output` → "Get task output: term_…" → + // "/bin/bash -lc 'pnpm dev …' (term_b0d)", so the title fallback below named + // the same call three different things and finally collapsed it to "bash" — + // where the history path (which reads `x.ai/tool.name`) kept the real name. + // + // Placed AFTER `inferFromInput` so every input-shape classification is + // preserved (`search_replace` → "edit" via old_string/new_string, + // `run_terminal_command` → "bash" via command, …) and this only decides the + // cases where the input shape is silent. See `extractGrokToolName` for the + // `use_tool` exclusion. + const grokToolName = extractGrokToolName(params.meta) + if (grokToolName) return normalizeToolName(grokToolName) + const byTitle = normalizeToolName(params.title ?? "") if (byTitle !== "tool") return byTitle @@ -623,3 +638,26 @@ function extractGrokPlanModeToolName( if (kind === "exit_plan") return "exit_plan_mode" return null } + +/** + * Grok's authoritative tool name from `_meta["x.ai/tool"].name` — the same + * field the history parser stores (`parsers/grok.rs`), and the only identity on + * the live wire that does NOT mutate across a call's lifecycle (`title` does). + * + * `use_tool` — Grok's generic MCP envelope — is excluded: the backend unwraps it + * and puts the inner `__` name in the TITLE + * (`connection.rs::unwrap_grok_use_tool`), so the envelope name would send every + * MCP call (delegation companions included) to the generic tool card. + */ +function extractGrokToolName( + meta: Record | null | undefined +): string | null { + if (!meta || typeof meta !== "object") return null + const tool = (meta as Record)["x.ai/tool"] + if (!tool || typeof tool !== "object") return null + const name = (tool as Record).name + if (typeof name !== "string") return null + const trimmed = name.trim() + if (!trimmed || trimmed === "use_tool") return null + return trimmed +}