From c778044f7b90c68ab4fd8c28d67c407943d05701 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 30 Jul 2026 23:03:06 +0800 Subject: [PATCH 1/3] fix(grok): render background commands and drop injected reminders A command Grok runs in the background showed nothing of what it did. Its launch is acknowledged with a task id, and the model then polls the task with `get_command_or_subagent_output` -- a call whose result carries no ACP content and no prompt text at all: the command, its status, exit code and shell output live only inside a structured `TaskOutput` envelope that neither the live path nor the history parser knew how to read. Both showed an empty card. A failed dev server that exited immediately was indistinguishable from one still serving, and the reason was nowhere in the conversation. That envelope is now passed through whole by one function both paths call, so live streaming and a later reload hand the frontend the same bytes, and those polls collapse into the same background-task card Claude Code's do: one row per task id showing the command, a status badge, the exit code, and the output through the ANSI terminal. Grok's own launch acknowledgement is recognized too, so the command that started the task keeps its concise background-launch form instead of repeating the notice. The envelope is bounded below the live emitter's single-message cap, since that truncation would otherwise leave the frontend a fragment of JSON; a fragment that arrives anyway falls back to the plain result view rather than a half-read task. The same tool also polls sub-agents, whose result is a different payload entirely. So the card claims a call by its envelope, and by input shape only while the call is still in flight -- long enough for the live card to render in the lane it will settle into. A settled sub-agent poll keeps the generic rendering it had. Live and history also disagreed on what that poll even was. Grok rewrites a tool call's title as it progresses, ending on the command being polled, so the title fallback named one call three different things and finally collapsed it to "bash", folding it into the tool group of the very command it was reporting on -- while history, reading Grok's own tool metadata, kept the real name. Live now reads that metadata as well. It is consulted after input-shape detection, so every existing classification is unchanged, and the generic MCP envelope is excluded because the inner tool name is already recovered into the title. Grok injects its own notices -- a background task finishing, most of them -- as user messages flagged to stay out of the scrollback, which its own terminal honours. Rendered as a user bubble, one of those split a single reply into two turns with a raw `` block wedged between them. They are now skipped, before the turn boundary is decided, so a notice delivered mid-turn cannot cut a reply in half either. The finished-task snapshot stays unrendered. It is keyed by task id, where the tool call it belongs to is keyed by call id, so the guard built around it never once matched and is gone. Applying it would mark the launching call failed for a command that started fine, which is neither what Grok reports on the wire nor what the live path -- which never receives that notification -- could ever show. The failure shows where it happened, on the poll. --- src-tauri/src/acp/connection.rs | 49 +++- src-tauri/src/parsers/grok.rs | 231 +++++++++++++---- .../message/background-task-card.test.tsx | 29 +++ src/lib/background-task.test.ts | 195 +++++++++++++++ src/lib/background-task.ts | 236 +++++++++++++++--- src/lib/tool-call-normalization.test.ts | 94 +++++++ src/lib/tool-call-normalization.ts | 38 +++ 7 files changed, 784 insertions(+), 88 deletions(-) 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/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/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 +} From 3557b9cb8c7422569f80876d7adf62541527f703 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 30 Jul 2026 23:12:13 +0800 Subject: [PATCH 2/3] chore(acp): bump pinned agent versions - opencode 1.18.8 -> 1.18.10 - cline 3.0.46 -> 3.0.47 - codebuddy 2.128.0 -> 2.130.0 - kimi-code 0.29.2 -> 0.31.0 - grok 0.2.112 -> 0.2.114 --- src-tauri/src/acp/registry.rs | 50 +++++++++++++++++------------------ 1 file changed, 25 insertions(+), 25 deletions(-) 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", From 2418e7dd45f33196a1496effcfa084c486fe5079 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 30 Jul 2026 23:30:35 +0800 Subject: [PATCH 3/3] # Release version 0.22.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(settings): **Kimi Code with your own API key now gets a reasoning picker.** A Reasoning card in the panel declares which levels to offer and which one to start on — until now only subscription accounts had the option. - fix(acp): **A long-running command no longer freezes the conversation.** Agents that run their commands through the ACP terminal — Grok among them — got stuck halfway through a session with Stop doing nothing, because a command that never exits on its own blocked everything behind it. Such a command now runs in the background with its output streaming as it comes, and the turn stays interruptible. Agents that run commands themselves, Claude Code and Codex included, were never affected (#394). - fix(codex): **Codex searches read like searches.** A `rg` or `grep` shows as "Grep " with results grouped per file and every line clickable to open it there, instead of a raw JSON blob. Claude, Cline, Gemini, OpenCode and Cursor searches get the same view. - fix(grok): **A command Grok runs in the background finally shows what it did.** Each task gets a card with the command, its status, exit code and terminal output, where it used to leave an empty one. Grok's internal notices no longer cut a reply in half either. - fix(composer): **File and command references show as badges wherever text lands in the composer.** Quick messages, restored drafts, queued-message edits, expert and Office templates and saved automations used to leave raw `[label](file:…)` text behind. A Windows path also stops gaining a backslash every time the message is reopened. - fix(acp): **Quitting codeg actually shuts the agents down.** Agent CLIs and the processes they start no longer linger after the window closes — thanks to @noxenys for the fix (#389). - chore(acp): **Updated the built-in agents.** OpenCode 1.18.10, Cline 3.0.47, CodeBuddy 2.130.0, Kimi Code 0.31.0 and Grok 0.2.114. ----------------------------- # 发布版本 0.22.2 - 功能(设置):**用自己 API Key 的 Kimi Code 也有了思考强度选择。** 面板新增「推理」卡片,用于声明提供哪些强度、默认用哪一档——此前只有订阅账号才有这个选项。 - 修复(智能体):**长时间运行的命令不再把整个会话冻住。** 通过 ACP 终端执行命令的智能体(Grok 就是其一)会聊到一半卡住、点「停止」也没反应,因为一条不会自己结束的命令堵住了它后面的一切。这类命令现在在后台运行,输出边跑边显示,回合随时可以打断。自己执行命令的智能体(包括 Claude Code 与 Codex)从来不受此影响(#394)。 - 修复(Codex):**Codex 的搜索终于长得像搜索。** 一次 `rg` 或 `grep` 不再是一坨原始 JSON,而是显示为「Grep <关键词>」,结果按文件分组,每一行都能点击跳转。Claude、Cline、Gemini、OpenCode 与 Cursor 的搜索也同样受益。 - 修复(Grok):**Grok 在后台跑的命令,终于能看到它干了什么。** 每个任务都有自己的卡片,包含命令、状态、退出码和终端输出,而不再是一张空卡片。Grok 的内部通知也不会再把回复从中间劈开。 - 修复(输入框):**只要文本被填进输入框,文件与命令引用就会显示成徽章。** 快捷消息、恢复的草稿、编辑排队中的消息、专家与办公模板、已保存的自动化任务,过去都还留着 `[label](file:…)` 的原始写法。Windows 路径也不会每重新打开一次就多出一层反斜杠了。 - 修复(智能体):**退出 codeg 时智能体真的会被关掉。** 智能体 CLI 及它拉起的进程不会再在窗口关闭后继续残留——感谢 @noxenys 贡献的修复(#389)。 - 维护(智能体):**更新内置智能体版本。** OpenCode 1.18.10、Cline 3.0.47、CodeBuddy 2.130.0、Kimi Code 0.31.0、Grok 0.2.114。 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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",