Files
orca/src/shared/native-chat-edit-normalize.ts
T
Merge Sim 4282699ab9 fix(native-chat): only read a patch where a patch was actually run
Recovering the patch envelope from any value of a tool's payload meant a
write's own content was searched for one. A file documenting the patch
format rendered a card for the file its example names, while the file
actually written never appeared at all — the call and its result were
consumed by that card, so nothing was left to correct it. Two changes: the
envelope is recovered only for the tools that run one, never for a file
edit whose payload is content; and only patch- or command-bearing arguments
are searched, still including the words of an argument vector, which is
where the envelope sits when a command tool applies it.

The call payload is decoded back where it is needed rather than at the
transcript decoder. Decoding it there changed the shape every reader of a
tool's input sees, including the surface that recognises a question payload
from any tool by shape alone: a tool whose arguments happened to carry that
shape raised a question card pinned over the composer. That decode now
happens inside the envelope recovery, the one consumer that needs the
structure.

A card also states an edit as made, so it now takes evidence that it landed
— the provider reporting the call complete, or a result that is not an
error. A turn that stopped before its call was answered reported an edit
that may never have applied. This replaces the working-turn heuristic in the
view, so the rule lives in one place.

Two files still went missing. A multi-file patch has no per-file split, so
it rendered as one card under the first file's name, with the later files'
rows and their gutter numbers beneath it — a card asserting a false file
position. Patch text is now split on its file boundaries, one card per file,
each named by its own header, with a rename and a `/dev/null` side read from
the same headers. And an envelope section that names a file but carries no
body was dropped rather than reported, which is the same silent loss the
delete case was fixed for.
2026-09-04 21:06:35 -07:00

305 lines
11 KiB
TypeScript

import { editFilesFromBeginPatch, unwrapBeginPatch } from './native-chat-begin-patch'
import { editLinesFromContents } from './native-chat-edit-lcs'
import {
finalizeEditFile,
pushEditGap,
type NativeChatEditFile,
type NativeChatEditLine
} from './native-chat-edit-model'
import {
editLinesFromUnifiedPatch,
editLinesFromWholeFile,
unifiedPatchSections
} from './native-chat-unified-patch'
import type { NativeChatEditPatch } from './native-chat-types'
// `NotebookEdit` is deliberately absent: its input carries only the new cell
// source, so a card would render an unchanged cell as wholly added. It falls
// through to the generic tool view instead.
const CLAUDE_EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace'])
/** Tools whose input may wrap a `*** Begin Patch` envelope — Codex sends
* `apply_patch` as the source of a command tool. */
const PATCH_ENVELOPE_TOOLS = new Set(['apply_patch', 'exec', 'shell', 'local_shell'])
/** Tools whose whole payload is patch text. `Diff` reaches its patch only
* through the result, because the structured journal projects a diff item as a
* call carrying just the path. */
const PATCH_TEXT_TOOLS = new Set(['apply_patch', 'Diff'])
export function isEditToolName(name: string): boolean {
return CLAUDE_EDIT_TOOLS.has(name) || PATCH_ENVELOPE_TOOLS.has(name) || PATCH_TEXT_TOOLS.has(name)
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function text(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
/** Rows straight from resolved hunks, which is the only path with true numbers
* for a provider that reports its edits as a snippet pair. */
function linesFromEditPatch(patch: NativeChatEditPatch): NativeChatEditLine[] {
const lines: NativeChatEditLine[] = []
for (const hunk of patch.hunks) {
// Hunks are separate regions of the file; run together the gutter jumps
// from one to the next with nothing marking the skipped span.
pushEditGap(lines)
let oldNo = hunk.oldStart
let newNo = hunk.newStart
for (const raw of hunk.lines) {
if (raw.startsWith('+')) {
lines.push({ kind: 'add', text: raw.slice(1), oldLineNumber: null, newLineNumber: newNo })
newNo += 1
} else if (raw.startsWith('-')) {
lines.push({ kind: 'del', text: raw.slice(1), oldLineNumber: oldNo, newLineNumber: null })
oldNo += 1
} else {
lines.push({
kind: 'context',
text: raw.startsWith(' ') ? raw.slice(1) : raw,
oldLineNumber: oldNo,
newLineNumber: newNo
})
oldNo += 1
newNo += 1
}
}
}
return lines
}
/** A whole-content write looks identical whether it created the file or
* overwrote one, so only positive evidence may claim a creation. With no
* evidence either way this errs toward the weaker claim: calling a creation an
* edit is imprecise, while calling an overwrite a creation is false and paints
* an existing file as wholly new. */
const CREATED_FILE_RESULT = /^\s*File created successfully/
function wholeContentChangeKind(
input: Record<string, unknown>,
output: string | undefined
): 'added' | 'edited' {
if (text(input.command) === 'create') {
return 'added'
}
return output !== undefined && CREATED_FILE_RESULT.test(output) ? 'added' : 'edited'
}
/** `MultiEdit` carries its snippet pairs in `edits[]`, not at the top level. */
function multiEditFiles(input: Record<string, unknown>, path: string): NativeChatEditFile[] | null {
if (!Array.isArray(input.edits)) {
return null
}
const lines: NativeChatEditLine[] = []
let truncated = false
for (const entry of input.edits) {
const edit = record(entry)
const oldString = text(edit?.old_string) ?? text(edit?.oldString)
const newString = text(edit?.new_string) ?? text(edit?.newString)
if (oldString === null && newString === null) {
continue
}
// Each entry is its own snippet, so it starts a new region.
pushEditGap(lines)
const diffed = editLinesFromContents(oldString ?? '', newString ?? '')
lines.push(...diffed.lines)
truncated ||= diffed.truncated
}
if (lines.length === 0) {
return null
}
return [
finalizeEditFile({
path,
oldPath: null,
changeKind: 'edited',
lines,
// A snippet pair cannot say where in the file it sits.
lineNumbersKnown: false,
truncated
})
]
}
function claudeEditFiles(
name: string,
input: Record<string, unknown>,
output: string | undefined
): NativeChatEditFile[] | null {
const path = text(input.file_path) ?? text(input.path) ?? 'file'
if (name === 'MultiEdit') {
return multiEditFiles(input, path)
}
const oldString = text(input.old_string) ?? text(input.oldString)
const newString = text(input.new_string) ?? text(input.newString)
const content = text(input.content) ?? text(input.file_text)
if (oldString === null && content !== null) {
const whole = editLinesFromWholeFile(content, 'add')
return [
finalizeEditFile({
path,
oldPath: null,
changeKind: wholeContentChangeKind(input, output),
lines: whole.lines,
lineNumbersKnown: true,
truncated: whole.truncated
})
]
}
if (oldString === null && newString === null) {
return null
}
const diffed = editLinesFromContents(oldString ?? '', newString ?? content ?? '')
return [
finalizeEditFile({
path,
oldPath: null,
changeKind: 'edited',
lines: diffed.lines,
// A snippet pair cannot say where in the file it sits.
lineNumbersKnown: false,
truncated: diffed.truncated
})
]
}
function codexChangeFiles(changes: unknown[]): NativeChatEditFile[] {
return changes.flatMap((entry) => {
const change = record(entry)
const path = text(change?.path)
const diff = text(change?.diff)
if (!change || !path || !diff) {
return []
}
const kind = record(change.kind)
const kindType = text(kind?.type) ?? text(change.kind) ?? 'update'
const movePath = text(kind?.move_path) ?? text(change.movePath)
if (kindType === 'add' || kindType === 'delete') {
// Add and delete arrive as raw file content, with no hunk header or signs.
const whole = editLinesFromWholeFile(diff, kindType === 'add' ? 'add' : 'del')
return [
finalizeEditFile({
path,
oldPath: null,
changeKind: kindType === 'add' ? 'added' : 'deleted',
lines: whole.lines,
lineNumbersKnown: true,
truncated: whole.truncated
})
]
}
// A move is appended to the diff body as prose rather than a header field.
const body = diff.replace(/\n*Moved to: .*$/, '')
const parsed = editLinesFromUnifiedPatch(body)
if (!parsed) {
return []
}
return [
finalizeEditFile({
path: movePath ?? path,
oldPath: movePath ? path : null,
changeKind: movePath ? 'renamed' : 'edited',
lines: parsed.lines,
lineNumbersKnown: parsed.lineNumbersKnown,
truncated: parsed.truncated
})
]
})
}
/** One diff model for a tool call and its result, across every shape the
* supported agents use to report a file edit. */
export function editFilesFromToolPair(pair: {
name: string
input: unknown
/** Provider lifecycle for the call, when the lane reports one. */
state?: 'running' | 'completed' | 'failed'
result?: { output?: string; isError?: boolean; editPatch?: NativeChatEditPatch }
}): NativeChatEditFile[] | null {
// A card states the edit as made, so it takes evidence that it landed: the
// provider reporting the call complete, or a result that is not an error.
// Anything else — failed, still running, or a turn that stopped before the
// call was answered — keeps the generic tool view and its error body.
if (pair.state === 'failed' || pair.state === 'running' || pair.result?.isError === true) {
return null
}
if (pair.state !== 'completed' && pair.result === undefined) {
return null
}
const input = record(pair.input)
const patch = pair.result?.editPatch
if (patch && patch.hunks.length > 0) {
return [
finalizeEditFile({
path: patch.filePath ?? text(input?.file_path) ?? 'file',
oldPath: null,
changeKind: 'edited',
lines: linesFromEditPatch(patch),
lineNumbersKnown: true
})
]
}
// Only a tool that runs a patch may be searched for an envelope: a file's own
// contents can quote one, and scanning a write's payload rendered a card for
// the quoted file while the file actually written never appeared.
if (PATCH_ENVELOPE_TOOLS.has(pair.name)) {
const envelope = unwrapBeginPatch(pair.input)
const files = envelope ? editFilesFromBeginPatch(envelope) : []
if (files.length > 0) {
return files
}
}
if (input && Array.isArray(input.changes)) {
const files = codexChangeFiles(input.changes)
if (files.length > 0) {
return files
}
}
if (input && CLAUDE_EDIT_TOOLS.has(pair.name)) {
return claudeEditFiles(pair.name, input, pair.result?.output)
}
if (!PATCH_TEXT_TOOLS.has(pair.name)) {
return null
}
// The result fallback is scoped to `Diff`, whose call carries only a path.
// Reading any command tool's output as a patch reclassified `git diff` as a
// file edit and swallowed the command line with it.
const patchText =
text(input?.patch) ?? text(input?.diff) ?? (pair.name === 'Diff' ? pair.result?.output : null)
if (!patchText) {
return null
}
// One card per file the patch touches: run together, the later files' rows
// and gutter numbers sit under the first file's name.
const split = unifiedPatchSections(patchText)
const callerPath = text(input?.path) ?? text(input?.file_path)
// For a single-file patch the call names the file it is reporting on, which
// is the provider's own path. A multi-file patch has no one path, so each
// section is named by its own header.
const named = (section: { path: string | null }): string =>
(split.sections.length === 1 ? (callerPath ?? section.path) : (section.path ?? callerPath)) ??
'file'
const files = split.sections.flatMap((section) => {
const parsed = editLinesFromUnifiedPatch(section.body)
if (!parsed && section.path === null) {
return []
}
return [
finalizeEditFile({
path: named(section),
oldPath: section.oldPath,
changeKind: section.changeKind,
lines: parsed?.lines ?? [],
lineNumbersKnown: parsed?.lineNumbersKnown ?? false,
truncated: split.truncated || (parsed?.truncated ?? false)
})
]
})
return files.length > 0 ? files : null
}