mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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.
305 lines
11 KiB
TypeScript
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
|
|
}
|