Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions src/components/message/content-parts-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions src/lib/adapters/ai-elements-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
// <SearchResultsOutput> 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",
Expand Down
54 changes: 44 additions & 10 deletions src/lib/adapters/ai-elements-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2131,20 +2154,25 @@ export function adaptMessageTurn(
adaptedContent.push(...imageParts)
continue
}
const isNoMatch = isCodexGrepNoMatchResult(
block.tool_name,
matchedResult
)
adaptedContent.push({
type: "tool-call",
toolCallId,
toolName: block.tool_name,
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,
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions src/lib/codex-command-action.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"

import {
isCodexGrepNoMatchEnvelope,
parseCodexCommandEnvelope,
parseCodexListFilesTitle,
parseCodexSearchTitle,
Expand Down Expand Up @@ -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
)
})
})
23 changes: 23 additions & 0 deletions src/lib/codex-command-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<SearchResultsOutput>`,
* 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
}
Loading