mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Splitting a patch into its files only ever looked for a boundary outside a hunk, and nothing reopened that state once the first hunk began, so every file after the first was swallowed as the first one's body. A `--- `/`+++ ` pair inside a hunk is now a boundary too, but only when a hunk header follows it immediately: a removed `-- x` over an added `++ y` is never followed by a column-0 header, which is what keeps the guard against reading content as structure intact. One producer cannot be recovered by any parser: it joins several files' patches and keeps a count where the path goes, so nothing in what reaches here names a file. That shape is refused rather than rendered under a name no file has. Recovering the per-file paths belongs to the producer and is filed separately. A clipped body carries its own marker in its text, and the bound that clips it is six times smaller than this module's, so it fires first. Read as content, the marker became a numbered line of the file and the rows before it were reported complete. It is recognised at the end of the text, removed, and reported as the truncation it is — the footer says so and the copied text no longer carries it. Also: a move appended to the body as prose is now read as a rename on every lane that carries the body as text, not just the one that also carries the destination as a field, where it had been rendering as a numbered line of the file it moved. The call's own path no longer wins over a rename's destination, which is only ever in the header, and only sections that name a file count toward deciding whether the call names the one file at hand. A command that merely quotes an envelope — writing documentation about the format — must now also invoke the tool that applies one. And two compared directories are no longer called a rename: only a header that states both sides as such is evidence of a move.
175 lines
6.5 KiB
TypeScript
175 lines
6.5 KiB
TypeScript
import { finalizeEditFile, type NativeChatEditFile } from './native-chat-edit-model'
|
|
import { editLinesFromUnifiedPatch, editLinesFromWholeFile } from './native-chat-unified-patch'
|
|
|
|
const BEGIN = '*** Begin Patch'
|
|
const END = '*** End Patch'
|
|
const FILE_HEADER = /^\*\*\* (Add|Update|Delete) File: (.+)$/
|
|
const MOVE_HEADER = /^\*\*\* Move to: (.+)$/
|
|
/** Envelope structure that carries no file content of its own. */
|
|
const CONTROL_LINE = /^\*\*\* (?:End of File|Environment ID:)/
|
|
|
|
/** The envelope reaches a command tool as one of its patch or command
|
|
* arguments, either whole or as one word of the argument vector it runs.
|
|
* Recover its text. Callers must gate this on the tool being one that runs a
|
|
* patch: a file's own contents may quote an envelope.
|
|
*
|
|
* `requireApplyCommand` is for a general command tool, where the envelope
|
|
* proves nothing on its own — a command writing documentation quotes one
|
|
* without applying it, and the command must say it is applying it. */
|
|
export function unwrapBeginPatch(
|
|
input: unknown,
|
|
options?: { requireApplyCommand?: boolean }
|
|
): string | null {
|
|
const source = envelopeSource(input, options?.requireApplyCommand === true)
|
|
if (!source) {
|
|
return null
|
|
}
|
|
const start = source.indexOf(BEGIN)
|
|
if (start === -1) {
|
|
return null
|
|
}
|
|
const end = source.indexOf(END, start)
|
|
if (end === -1) {
|
|
// Without the closing marker there is nothing separating the patch body from
|
|
// whatever the command line continues with, and trailing shell syntax would
|
|
// render as file content the agent never wrote.
|
|
return null
|
|
}
|
|
return source.slice(start, end + END.length)
|
|
}
|
|
|
|
/** The arguments that carry a patch or the command line that applies one. Only
|
|
* these are searched: any other value is data the tool operates on, and a file
|
|
* whose own contents quote an envelope would otherwise be read as a patch
|
|
* against some other file entirely. */
|
|
const ENVELOPE_ARGUMENTS = ['input', 'command', 'patch', 'arguments', 'script'] as const
|
|
/** What a command tool runs to apply an envelope, as opposed to quoting one. */
|
|
const APPLY_COMMAND = 'apply_patch'
|
|
|
|
/** The call payload may itself be a string holding JSON. Decoding it here, in
|
|
* the one consumer that needs its structure, keeps every other reader of the
|
|
* call input seeing exactly what the provider sent. */
|
|
function envelopeSource(input: unknown, requireApplyCommand: boolean): string | null {
|
|
if (typeof input === 'string') {
|
|
const record = jsonRecord(input)
|
|
return record
|
|
? envelopeArgument(record, requireApplyCommand)
|
|
: applied(input, requireApplyCommand)
|
|
}
|
|
return typeof input === 'object' && input !== null
|
|
? envelopeArgument(input as Record<string, unknown>, requireApplyCommand)
|
|
: null
|
|
}
|
|
|
|
function applied(value: string, requireApplyCommand: boolean): string | null {
|
|
return !requireApplyCommand || value.includes(APPLY_COMMAND) ? value : null
|
|
}
|
|
|
|
function jsonRecord(value: string): Record<string, unknown> | null {
|
|
if (!value.trimStart().startsWith('{')) {
|
|
return null
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(value)
|
|
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** A command tool's argument is a vector, so the envelope sits one level in and
|
|
* the words that apply it may be a different element than the envelope. */
|
|
function envelopeArgument(
|
|
record: Record<string, unknown>,
|
|
requireApplyCommand: boolean
|
|
): string | null {
|
|
for (const key of ENVELOPE_ARGUMENTS) {
|
|
const value = record[key]
|
|
const words =
|
|
typeof value === 'string'
|
|
? [value]
|
|
: Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
: []
|
|
if (requireApplyCommand && !words.some((word) => word.includes(APPLY_COMMAND))) {
|
|
continue
|
|
}
|
|
const word = words.find((entry) => entry.includes(BEGIN))
|
|
if (word) {
|
|
return word
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Splits a `*** Begin Patch` envelope into one entry per file it touches. */
|
|
export function editFilesFromBeginPatch(envelope: string): NativeChatEditFile[] {
|
|
const sections: { kind: 'Add' | 'Update' | 'Delete'; path: string; body: string[] }[] = []
|
|
let movePath: string | null = null
|
|
const moves = new Map<number, string>()
|
|
|
|
// Split on both newline forms once, so every marker below can be matched
|
|
// exactly rather than each pattern having to tolerate a trailing `\r`.
|
|
for (const raw of envelope.split(/\r?\n/)) {
|
|
const header = FILE_HEADER.exec(raw)
|
|
if (header) {
|
|
sections.push({
|
|
kind: header[1] as 'Add' | 'Update' | 'Delete',
|
|
path: header[2]!.trim(),
|
|
body: []
|
|
})
|
|
continue
|
|
}
|
|
const move = MOVE_HEADER.exec(raw)
|
|
if (move && sections.length > 0) {
|
|
movePath = move[1]!.trim()
|
|
moves.set(sections.length - 1, movePath)
|
|
continue
|
|
}
|
|
if (raw === BEGIN || raw === END || CONTROL_LINE.test(raw) || sections.length === 0) {
|
|
continue
|
|
}
|
|
sections.at(-1)!.body.push(raw)
|
|
}
|
|
|
|
return sections.flatMap((section, index) => {
|
|
const body = section.body.join('\n')
|
|
const moved = moves.get(index) ?? null
|
|
if (section.kind === 'Add' || section.kind === 'Delete') {
|
|
const sign = section.kind === 'Add' ? '+' : '-'
|
|
// Add/Delete bodies carry a sign per line but no hunk header.
|
|
const stripped = section.body
|
|
.map((line) => (line.startsWith(sign) ? line.slice(1) : line))
|
|
.join('\n')
|
|
const whole = editLinesFromWholeFile(stripped, section.kind === 'Add' ? 'add' : 'del')
|
|
return [
|
|
finalizeEditFile({
|
|
path: section.path,
|
|
oldPath: null,
|
|
changeKind: section.kind === 'Add' ? 'added' : 'deleted',
|
|
lines: whole.lines,
|
|
lineNumbersKnown: true,
|
|
truncated: whole.truncated
|
|
})
|
|
]
|
|
}
|
|
// The first chunk of an update may carry no hunk header at all, and a
|
|
// section may carry no body either. The envelope named the file, so it is
|
|
// reported with whatever rows it has rather than dropped from a multi-file
|
|
// envelope with nothing to say it went missing.
|
|
const parsed = editLinesFromUnifiedPatch(body, { implicitFirstHunk: true })
|
|
return [
|
|
finalizeEditFile({
|
|
path: moved ?? section.path,
|
|
oldPath: moved ? section.path : null,
|
|
changeKind: moved ? 'renamed' : 'edited',
|
|
lines: parsed?.lines ?? [],
|
|
lineNumbersKnown: parsed?.lineNumbersKnown ?? false,
|
|
truncated: parsed?.truncated ?? false
|
|
})
|
|
]
|
|
})
|
|
}
|