Files
orca/src/shared/native-chat-edit-normalize.ts
T
Brennan BensonandMerge Sim e80fae0c4d Summarize turn file changes and preserve resolved prompt receipts (#19229)
* feat(chat): summarize turn changes and retain resolution receipts

* fix(chat): defer turn diff details and localize resolution times

* fix: complete approval projection test fixture

* Align turn diff disclosure chevron and indent details

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 17:50:51 -07:00

279 lines
9.9 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 { editFilesFromPatchText, splitMoveMarker } from './native-chat-edit-patch-files'
import { editLinesFromUnifiedPatch, editLinesFromWholeFile } 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'])
/** Command tools, which run a patch as one of many things they can run, so a
* quoted envelope is not evidence that one was applied. */
const COMMAND_PATCH_TOOLS = new Set(['exec', 'shell', 'local_shell'])
/** Tools whose input may wrap a `*** Begin Patch` envelope. The dedicated patch
* tool applies whatever it is given; a command tool must say that it is. */
const PATCH_ENVELOPE_TOOLS = new Set(['apply_patch', ...COMMAND_PATCH_TOOLS])
/** 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
})
]
}
const parsed = editLinesFromUnifiedPatch(splitMoveMarker(diff).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, {
requireApplyCommand: COMMAND_PATCH_TOOLS.has(pair.name)
})
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
}
return editFilesFromPatchText(patchText, text(input?.path) ?? text(input?.file_path))
}