diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index d6f1faea90..534a12a102 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -56,7 +56,10 @@ import { } from "./context-compaction-card" import { FeedbackCheckResultCard } from "./feedback-check-result-card" import { SearchResultsOutput } from "./search-results-output" -import { parseCodexCommandEnvelope } from "@/lib/codex-command-action" +import { + isCodexGrepNoMatchEnvelope, + parseCodexCommandEnvelope, +} from "@/lib/codex-command-action" import { CODEX_SCRIPT_TOOL_NAME, parseCodexScriptCard, @@ -2532,18 +2535,17 @@ const ToolCallPart = memo(function ToolCallPart({ // codex appears to derive the tool status from the exit code, so an rg/grep // "no matches" (exit 1, no output) can arrive as a FAILED call and land on - // the error channel. Recognise exactly that shape as an empty result instead - // of a red envelope dump. Scoped to `grep`: exit 1 means "nothing selected" - // only for grep-likes — for the list-files commands that classify as `glob` - // (ls/find/…) it is a genuine failure, and a successful empty listing - // already arrives with exit 0. A real grep failure (exit ≥ 2, or any stderr - // text) keeps the error rendering, as does any non-codex error string. + // the error channel. `adaptMessageTurn` normally takes that shape off the + // error channel entirely (same `isCodexGrepNoMatchEnvelope` predicate, so + // the card's status and this body can never disagree); this arm still + // catches the adapter-independent callers — an `agent_stats` child call, an + // export/replay part built outside the turn adapter — and renders an empty + // result instead of a red envelope dump. A real grep failure (exit ≥ 2, or + // any stderr text) keeps the error rendering, as does any non-codex error + // string. if (typeof part.errorText === "string") { if (toolNameLower !== "grep") return null - const envelope = parseCodexCommandEnvelope(part.errorText) - const noMatches = - envelope?.exitCode === 1 && envelope.output.trim().length === 0 - return noMatches ? "" : null + return isCodexGrepNoMatchEnvelope(part.errorText) ? "" : null } if (typeof part.output !== "string") return null diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index 8899e9efd7..7e463a0e39 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -1481,6 +1481,118 @@ describe("adaptMessageTurn plan handling", () => { ) }) +describe("adaptMessageTurn — Codex grep no-match results", () => { + const msgText = { + attachedResources: "Attached resources", + toolCallFailed: "Tool failed", + } + + function adaptSearchResult({ + toolName = "Search for 'definitely absent'", + output = JSON.stringify({ exit_code: 1, formatted_output: "" }), + isError = true, + pairing = "id", + isStreaming = false, + }: { + toolName?: string + output?: string + isError?: boolean + pairing?: "id" | "position" + isStreaming?: boolean + } = {}): AdaptedToolCallPart { + const toolUseId = pairing === "id" ? "search-1" : null + const adapted = adaptMessageTurn( + { + id: `codex-search-${pairing}-${isStreaming ? "live" : "reload"}`, + role: "assistant", + timestamp: "2026-09-04T00:00:00.000Z", + blocks: [ + { + type: "tool_use", + tool_use_id: toolUseId, + tool_name: toolName, + input_preview: JSON.stringify({ pattern: "definitely absent" }), + }, + { + type: "tool_result", + tool_use_id: toolUseId, + output_preview: output, + is_error: isError, + }, + ], + }, + msgText, + isStreaming + ) + const group = adapted.content[0] + if (group?.type !== "tool-group" || !group.items[0]) { + throw new Error("expected a grouped tool call") + } + return group.items[0] + } + + it.each([ + ["id", false], + ["id", true], + ["position", false], + ["position", true], + ] as const)( + "normalizes an exact exit-1 empty grep envelope for %s pairing (streaming=%s)", + (pairing, isStreaming) => { + const raw = JSON.stringify({ exit_code: 1, formatted_output: "" }) + const part = adaptSearchResult({ pairing, isStreaming, output: raw }) + + expect(part.state).toBe("output-available") + expect(part.errorText).toBeUndefined() + expect(part.output).toBe(raw) + } + ) + + // A shell that echoes a bare newline still means "no matches": + // renders any blank body that way, so the card status + // has to agree or the same result reads as red-with-"No matches". + it("normalizes a whitespace-only exit-1 grep envelope", () => { + const raw = JSON.stringify({ exit_code: 1, formatted_output: "\r\n" }) + const part = adaptSearchResult({ output: raw }) + + expect(part.state).toBe("output-available") + expect(part.errorText).toBeUndefined() + expect(part.output).toBe(raw) + }) + + it.each([ + [ + "an ordinary command", + "bash", + JSON.stringify({ exit_code: 1, formatted_output: "" }), + ], + [ + "a glob command", + "List files", + JSON.stringify({ exit_code: 1, formatted_output: "" }), + ], + [ + "grep output", + "Search for 'definitely absent'", + JSON.stringify({ + exit_code: 1, + formatted_output: "rg: permission denied", + }), + ], + [ + "a higher exit code", + "Search for 'definitely absent'", + JSON.stringify({ exit_code: 2, formatted_output: "" }), + ], + ["a non-Codex result", "Search for 'definitely absent'", ""], + ])("keeps %s on the error path", (_label, toolName, output) => { + const part = adaptSearchResult({ toolName, output }) + + expect(part.state).toBe("output-error") + expect(part.errorText).toBe(output || undefined) + }) +}) + describe("adaptMessageTurn — image tool results", () => { const msgText = { attachedResources: "Attached resources", diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index dc8f613a8d..3cfc5fb3b1 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -14,6 +14,7 @@ import { isDelegationStatusToolName, } from "@/lib/adapters/tool-kind-classifier" import { normalizeToolName } from "@/lib/tool-call-normalization" +import { isCodexGrepNoMatchEnvelope } from "@/lib/codex-command-action" import { isBackgroundTaskToolCall } from "@/lib/background-task" import { isContextCompactionMeta } from "@/lib/context-compaction" import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" @@ -1970,6 +1971,28 @@ function buildToolResultMap( return map } +/** + * Codex reports a ripgrep search with no matches as a failed ACP tool result: + * exit 1 with an otherwise empty command envelope. Treat only that exact shape + * as a successful presentation state. The ContentBlock and its raw envelope + * stay untouched, and every other nonzero result remains an error. + * + * Shares `isCodexGrepNoMatchEnvelope` with the search body in + * `content-parts-renderer`, which recognises the same envelope to render "No + * matches" instead of a raw JSON dump. Two predicates for one fact would let + * the card's status and its body disagree. + */ +function isCodexGrepNoMatchResult( + toolName: string, + result: ContentBlock & { type: "tool_result" } +): boolean { + if (!result.is_error || typeof result.output_preview !== "string") + return false + if (normalizeToolName(toolName) !== "grep") return false + + return isCodexGrepNoMatchEnvelope(result.output_preview) +} + /** * Transform a MessageTurn (from backend) to AdaptedMessage format. * Same correlation logic as adaptUnifiedMessage but operates on turn.blocks. @@ -2131,6 +2154,10 @@ export function adaptMessageTurn( adaptedContent.push(...imageParts) continue } + const isNoMatch = isCodexGrepNoMatchResult( + block.tool_name, + matchedResult + ) adaptedContent.push({ type: "tool-call", toolCallId, @@ -2138,13 +2165,14 @@ export function adaptMessageTurn( input: block.input_preview, state: isToolStillRunning ? "input-available" - : matchedResult.is_error + : matchedResult.is_error && !isNoMatch ? "output-error" : "output-available", output: matchedResult.output_preview, - errorText: matchedResult.is_error - ? matchedResult.output_preview || undefined - : undefined, + errorText: + matchedResult.is_error && !isNoMatch + ? matchedResult.output_preview || undefined + : undefined, agentStats: matchedResult.agent_stats ?? undefined, meta: block.meta ?? null, agentTranscript: matchedResult.agent_transcript ?? undefined, @@ -2171,18 +2199,24 @@ export function adaptMessageTurn( adaptedContent.push(...imageParts) continue } + const isNoMatch = isCodexGrepNoMatchResult( + block.tool_name, + positionalResult + ) adaptedContent.push({ type: "tool-call", toolCallId, toolName: block.tool_name, input: block.input_preview, - state: positionalResult.is_error - ? "output-error" - : "output-available", + state: + positionalResult.is_error && !isNoMatch + ? "output-error" + : "output-available", output: positionalResult.output_preview, - errorText: positionalResult.is_error - ? positionalResult.output_preview || undefined - : undefined, + errorText: + positionalResult.is_error && !isNoMatch + ? positionalResult.output_preview || undefined + : undefined, agentStats: positionalResult.agent_stats ?? undefined, meta: block.meta ?? null, agentTranscript: positionalResult.agent_transcript ?? undefined, diff --git a/src/lib/codex-command-action.test.ts b/src/lib/codex-command-action.test.ts index 3749cdbc38..7ba92f9b6a 100644 --- a/src/lib/codex-command-action.test.ts +++ b/src/lib/codex-command-action.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + isCodexGrepNoMatchEnvelope, parseCodexCommandEnvelope, parseCodexListFilesTitle, parseCodexSearchTitle, @@ -123,3 +124,44 @@ describe("parseCodexCommandEnvelope", () => { expect(parseCodexCommandEnvelope('"a string"')).toBeNull() }) }) + +describe("isCodexGrepNoMatchEnvelope", () => { + it("accepts exit 1 with empty or whitespace-only output", () => { + for (const formatted_output of ["", "\n", "\r\n", " "]) { + expect( + isCodexGrepNoMatchEnvelope( + JSON.stringify({ exit_code: 1, formatted_output }) + ) + ).toBe(true) + } + }) + + it("rejects a real failure: any diagnostic text, or exit >= 2", () => { + expect( + isCodexGrepNoMatchEnvelope( + JSON.stringify({ + exit_code: 1, + formatted_output: "rg: unclosed group", + }) + ) + ).toBe(false) + expect( + isCodexGrepNoMatchEnvelope( + JSON.stringify({ exit_code: 2, formatted_output: "" }) + ) + ).toBe(false) + }) + + it("rejects a successful search and anything that is not the envelope", () => { + expect( + isCodexGrepNoMatchEnvelope( + JSON.stringify({ exit_code: 0, formatted_output: "a.ts:1:hit" }) + ) + ).toBe(false) + expect(isCodexGrepNoMatchEnvelope("")).toBe(false) + expect(isCodexGrepNoMatchEnvelope("rg: no such file")).toBe(false) + expect(isCodexGrepNoMatchEnvelope(JSON.stringify({ exit_code: 1 }))).toBe( + false + ) + }) +}) diff --git a/src/lib/codex-command-action.ts b/src/lib/codex-command-action.ts index 8a98fb681e..dcebccd7c8 100644 --- a/src/lib/codex-command-action.ts +++ b/src/lib/codex-command-action.ts @@ -121,3 +121,26 @@ export function parseCodexCommandEnvelope( } return { output: obj.formatted_output, exitCode: obj.exit_code } } + +/** + * True for the one command envelope that means "the search ran fine and matched + * nothing": codex derives an ACP tool status from the process exit code, and + * rg/grep exit 1 when no line was selected, so a healthy negative search arrives + * as a FAILED tool call carrying `{exit_code: 1, formatted_output: ""}`. + * + * Callers MUST first establish that the tool is a grep-like search + * (`normalizeToolName(...) === "grep"`): exit 1 only means "nothing selected" + * for grep-likes — for the list-files commands that classify as `glob` + * (ls/find/…) it is a genuine failure, and a successful empty listing already + * arrives with exit 0. The tool-name gate lives at the call sites because + * `tool-call-normalization` imports this module, not the other way round. + * + * Whitespace-only output counts as empty, matching ``, + * which renders any blank body as "No matches" — a shell that echoes a bare + * newline must not flip the same result between the neutral and the error + * rendering. A real failure (exit ≥ 2, or any diagnostic text) is untouched. + */ +export function isCodexGrepNoMatchEnvelope(raw: string): boolean { + const envelope = parseCodexCommandEnvelope(raw) + return envelope?.exitCode === 1 && envelope.output.trim().length === 0 +}