Files
orca/src/shared/native-chat-diff.ts
T
NeilandOrca 02a1251c2d fix(native-chat): classify diff lines whose content begins with -- or ++ (#12459)
* fix(native-chat): stop diff colouring from misreading -- / ++ content lines as file headers

diffFromText skipped every line starting with --- / +++ as a file header, so a
deleted SQL/Lua '-- comment' (git emits '---<content>') or an added '++flag' fell
through to gray context with its marker still attached — and when it was the only
change, the two-marker gate dropped the coloured diff entirely.

Detect real headers structurally instead: an adjacent '--- <old>' / '+++ <new>'
pair outside any hunk. A hunk header or 'diff --git' line now also proves the text
is a diff, so a genuine single-line change renders while prose keeps the guard.

Co-authored-by: Orca <help@stably.ai>

* test(native-chat): adopt #12335 diff-collision vectors and add mobile parity

Pulls in @YuriNachos's test vectors from #12335 (header-less --- deletion, an
adjacent --x/++y content pair, mobile re-export parity) and adds the spaced
-- / ++ pair inside a hunk, which the pair-only rule in that PR misreads.

Co-authored-by: Orca <help@stably.ai>

* fix(native-chat): keep bare --- / +++ rules out of the diff marker count

Dropping the `---`/`+++` prefix exclusions made a bare `---` — a Markdown
thematic break or YAML document separator — classify as a deletion. Tool
results routinely carry those, so `---\na: 1\n---\nb: 2` went from correctly
rejected to rendering as a red diff.

A bare rule is never a file header (those need a path after the marker) and is
only content inside a hunk, so treat it as meta when outside one.

Fold the separate `isStructuredDiff` scan into the same pre-pass and skip
non-marker lines early, so the added guard costs no extra traversal: 5.1 -> 4.3
us per 120-line prose result, diff path unchanged.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-07 03:13:14 -07:00

147 lines
5.1 KiB
TypeScript

export type NativeChatDiffLineKind = 'add' | 'del' | 'context' | 'meta'
export type NativeChatDiffLine = {
kind: NativeChatDiffLineKind
text: string
}
const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch'])
const MAX_DIFF_CHARS = 32_000
const DEFAULT_MAX_DIFF_LINES = 120
const DIFF_TRUNCATED_LINE: NativeChatDiffLine = {
kind: 'meta',
text: '… diff truncated …'
}
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/
// Lines that open a new file section, so any hunk before them has ended.
const FILE_SECTION_START =
/^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/
// Markdown thematic break or YAML document separator, not a marker.
const BARE_RULE = /^(?:-{3,}|\+{3,})$/
type DiffStructure = {
/** Lines that are structure rather than content, so they never count as add/del. */
metaIndices: Set<number>
/** The text carries a hunk header or `diff --git`, so it is provably a diff. */
isStructuredDiff: boolean
}
/**
* Locates the `--- <old>` / `+++ <new>` file headers. A bare `---`/`+++` prefix
* is not enough to spot one: a removed line whose content began with `--`
* (SQL/Lua `-- comment`, C `--i`) is emitted as `---<content>`. Real headers
* always come as an adjacent pair and never appear inside a hunk.
*/
function scanDiffStructure(lines: string[]): DiffStructure {
const metaIndices = new Set<number>()
let isStructuredDiff = false
let inHunk = false
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (line.startsWith('@@')) {
inHunk = true
isStructuredDiff ||= HUNK_HEADER.test(line)
continue
}
if (FILE_SECTION_START.test(line)) {
inHunk = false
isStructuredDiff ||= line.startsWith('diff --git ')
continue
}
// Only marker lines can be a header or a rule; skip context and prose early.
if (inHunk || !(line.startsWith('-') || line.startsWith('+'))) {
continue
}
if (BARE_RULE.test(line)) {
metaIndices.add(index)
continue
}
if (line.startsWith('--- ') && (lines[index + 1] ?? '').startsWith('+++ ')) {
metaIndices.add(index)
metaIndices.add(index + 1)
index += 1
}
}
return { metaIndices, isStructuredDiff }
}
function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } {
if (typeof value !== 'string') {
return { lines: [], truncated: false }
}
const clipped = value.slice(0, MAX_DIFF_CHARS)
const lines = clipped.split('\n', maxLines + 1)
const truncated = value.length > MAX_DIFF_CHARS || lines.length > maxLines
const bounded = lines.slice(0, maxLines)
if (!truncated && bounded.at(-1) === '') {
bounded.pop()
}
return { lines: bounded, truncated }
}
export function diffFromToolCall(
name: string,
input: unknown,
maxLines = DEFAULT_MAX_DIFF_LINES
): NativeChatDiffLine[] | null {
if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) {
return null
}
const value = input as Record<string, unknown>
const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines)
const newLines = toLines(
value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text,
maxLines
)
const deleted = oldLines.lines.map((text): NativeChatDiffLine => ({ kind: 'del', text }))
const added = newLines.lines.map((text): NativeChatDiffLine => ({ kind: 'add', text }))
if (deleted.length === 0 && added.length === 0) {
return null
}
const path = value.file_path ?? value.path
const prefix: NativeChatDiffLine[] =
typeof path === 'string' ? [{ kind: 'meta', text: path }] : []
const combined = [...prefix, ...deleted, ...added]
const truncated = oldLines.truncated || newLines.truncated || combined.length > maxLines
return truncated ? [...combined.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : combined
}
export function diffFromText(
text: string,
maxLines = DEFAULT_MAX_DIFF_LINES
): NativeChatDiffLine[] | null {
if (text.length === 0) {
return null
}
const bounded = toLines(text, maxLines)
const { metaIndices, isStructuredDiff } = scanDiffStructure(bounded.lines)
let added = 0
let removed = 0
const lines = bounded.lines.map((line, index): NativeChatDiffLine => {
if (
metaIndices.has(index) ||
line.startsWith('@@') ||
line.startsWith('diff ') ||
line.startsWith('index ')
) {
return { kind: 'meta', text: line }
}
if (line.startsWith('+')) {
added += 1
return { kind: 'add', text: line.slice(1) }
}
if (line.startsWith('-')) {
removed += 1
return { kind: 'del', text: line.slice(1) }
}
return { kind: 'context', text: line }
})
// Proven diff text renders a single-line change; without that proof, two
// markers guard against colouring prose that merely opens a line with `-`.
if (added + removed < (isStructuredDiff ? 1 : 2)) {
return null
}
return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines
}