From 478db5183addbdf927d0228069bbf8a601da770e Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 12 May 2026 10:52:52 +0200 Subject: [PATCH] feat(tools): added strict conflict parsing for read/write tools - Added conflictCount metadata and warning badge output to read results for files with unresolved conflicts. - Added conflict detection parsing with strict marker matching and session-scoped conflict IDs. - Added write-path conflict resolution for `conflict://N` using token expansion and marker validation before splicing. - Added unit and integration tests for conflict scanning, history lifecycle, URI validation, and workflows. --- packages/coding-agent/CHANGELOG.md | 8 + .../src/modes/components/read-tool-group.ts | 9 + .../coding-agent/src/tools/conflict-detect.ts | 430 ++++++++++++++++++ packages/coding-agent/src/tools/index.ts | 6 + packages/coding-agent/src/tools/read.ts | 24 + packages/coding-agent/src/tools/write.ts | 76 ++++ .../test/tools/conflict-detect.test.ts | 385 ++++++++++++++++ .../test/tools/conflict-integration.test.ts | 337 ++++++++++++++ 8 files changed, 1275 insertions(+) create mode 100644 packages/coding-agent/src/tools/conflict-detect.ts create mode 100644 packages/coding-agent/test/tools/conflict-detect.test.ts create mode 100644 packages/coding-agent/test/tools/conflict-integration.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e3a5bade..acab28dda 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,22 +1,30 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Changed the `eval` tool input format to a single-line `*** Cell :"" [t:<duration>] [rst]` header per cell, replacing the `*** Begin <LANG>` / `*** End <LANG>` envelope and the standalone `*** Title:` / `*** Timeout:` / `*** Reset` directives. The lark grammar enforces a fixed attribute order; the runtime parser remains lenient (alias keys, bare positional tokens, single-quoted titles). ### Added +- Added shorthand content tokens `@ours`, `@theirs`, `@both`, and `@base` to conflict-resolution writes using `path: "conflict://<N>"` so replacement content can be composed from recorded conflict sections +- Added conflict count metadata to read results so conflict files now show a warning badge (`⚠ N`) in the read tool UI - Added support for explicit boolean `rst` values (`rst:true`, `rst:false`, `rst:1`, `rst:0`, `rst:yes`, `rst:no`, `rst:on`, `rst:off`) in `*** Cell` headers +- Added detection of unresolved git merge conflicts in `read` output: each marker block is registered with a session-stable id and surfaced in a footer with `ours`/`theirs` previews. Resolve a block by calling `write({ path: "conflict://<id>", content })` — the tool splices the recorded marker region (markers and all sides) with the supplied content and routes through the normal writethrough (LSP format/diagnostics, fs-cache invalidation). ### Changed +- Changed conflict marker scanning during `read` to only register fully formed, column-0 merge-marker blocks, so indented or malformed marker-like lines are no longer treated as conflicts +- Changed `write` conflict resolution to validate `conflict://` IDs and report clear errors for malformed or unknown conflict URIs - Changed the HTML transcript renderer to parse the new `*** Cell` headers while keeping the older `*** Begin <LANG>` and `===== ... =====` formats renderable for historical sessions. - Changed the `eval` tool parser so a stray non-marker line between cells no longer crashes with `null is not an object (evaluating 'BEGIN_RE.exec(lines[i])[1]')`; stray content is consumed without aborting parsing. - Changed `*** End` to be an optional, undocumented per-cell terminator (kept in the lark to satisfy GPT-trained models' natural terminator habit during constrained sampling). ### Fixed +- Fixed conflict resolution to verify the live file still contains recorded `<<<<<<<` and `>>>>>>>` markers before splicing, preventing stale conflict IDs from silently corrupting out-of-band-edited files +- Fixed `@base` token handling so two-way conflicts without a base section now return a clear error - Improved `*** Cell` header parsing to reject invalid `rst` values with a clear `invalid rst value` error ## [14.9.7] - 2026-05-12 diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index 9651b5e51..bdc7ebcb8 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -22,6 +22,7 @@ type ReadToolResultDetails = { from?: string; to?: string; }; + conflictCount?: number; }; type ReadToolGroupOptions = { @@ -41,6 +42,7 @@ type ReadEntry = { status: "pending" | "success" | "warning" | "error"; correctedFrom?: string; contentText?: string; + conflictCount?: number; }; /** Number of code lines to show in collapsed preview mode */ @@ -91,6 +93,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa } else { entry.correctedFrom = undefined; } + const conflictCount = + typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined; + entry.conflictCount = conflictCount; entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success"; // Store the text content for preview/expanded display const textContent = result.content?.find(c => c.type === "text")?.text; @@ -212,6 +217,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa if (entry.correctedFrom) { pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`); } + if (entry.conflictCount && entry.conflictCount > 0) { + const n = entry.conflictCount; + pathDisplay += ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`; + } return pathDisplay; } diff --git a/packages/coding-agent/src/tools/conflict-detect.ts b/packages/coding-agent/src/tools/conflict-detect.ts new file mode 100644 index 000000000..4161473b1 --- /dev/null +++ b/packages/coding-agent/src/tools/conflict-detect.ts @@ -0,0 +1,430 @@ +/** + * Detect and resolve unresolved git merge conflicts that surface in `read` + * output. + * + * Workflow: + * 1. `read` collects lines from disk as usual. + * 2. `scanConflictLines` inspects those lines (no extra I/O) for + * well-formed `<<<<<<<` / `=======` / `>>>>>>>` blocks. + * 3. Each completed block is registered with the session's + * `ConflictHistory`, which assigns it a stable id. + * 4. The read output is returned verbatim with a short footer naming + * every conflict id surfaced, and the agent calls + * `write({ path: "conflict://<id>", content })` to splice the + * recorded region with the chosen content. + * + * Marker shape is strict: only column-0 markers of the exact prefix length + * followed by either EOL or a single space + label count. Lines that + * merely start with `<` or `=` never match. + */ + +import type { ToolSession } from "./index"; +import { ToolError } from "./tool-errors"; + +const OURS_PREFIX = "<<<<<<<"; +const BASE_PREFIX = "|||||||"; +const SEPARATOR = "======="; +const THEIRS_PREFIX = ">>>>>>>"; + +export interface ConflictBlock { + /** 1-indexed line of the `<<<<<<<` marker. */ + startLine: number; + /** 1-indexed line of the `=======` separator. */ + separatorLine: number; + /** 1-indexed line of the `>>>>>>>` marker. */ + endLine: number; + /** 1-indexed line of the `|||||||` base marker (diff3 only). */ + baseLine?: number; + oursLabel?: string; + baseLabel?: string; + theirsLabel?: string; + oursLines: string[]; + baseLines?: string[]; + theirsLines: string[]; +} + +/** + * Scan an already-collected array of file lines for completed conflict + * blocks. `firstLineNumber` is the 1-indexed line number of `lines[0]` + * (so a windowed read starting at line 200 passes `firstLineNumber: 200`). + * + * Only fully-closed blocks (opener + separator + closer all present in + * the window) are returned. A block whose closer is past the window's + * tail is dropped — the agent will see the open marker and can widen + * the read. + */ +export function scanConflictLines(lines: readonly string[], firstLineNumber: number): ConflictBlock[] { + const blocks: ConflictBlock[] = []; + let phase: "idle" | "ours" | "base" | "theirs" = "idle"; + let partial: { + startLine: number; + oursLabel?: string; + oursLines: string[]; + baseLine?: number; + baseLabel?: string; + baseLines?: string[]; + separatorLine?: number; + theirsLines?: string[]; + } | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const ln = firstLineNumber + i; + + const oursLabel = matchMarker(line, OURS_PREFIX); + if (oursLabel !== null) { + partial = { startLine: ln, oursLabel: oursLabel || undefined, oursLines: [] }; + phase = "ours"; + continue; + } + + if (phase === "idle" || partial === null) continue; + + const baseLabel = matchMarker(line, BASE_PREFIX); + if (baseLabel !== null) { + if (phase !== "ours") { + partial = null; + phase = "idle"; + continue; + } + partial.baseLine = ln; + partial.baseLabel = baseLabel || undefined; + partial.baseLines = []; + phase = "base"; + continue; + } + + if (line === SEPARATOR) { + if (phase === "ours" || phase === "base") { + partial.separatorLine = ln; + partial.theirsLines = []; + phase = "theirs"; + } else { + partial = null; + phase = "idle"; + } + continue; + } + + const theirsLabel = matchMarker(line, THEIRS_PREFIX); + if (theirsLabel !== null) { + if (phase === "theirs" && partial.separatorLine !== undefined && partial.theirsLines) { + blocks.push({ + startLine: partial.startLine, + separatorLine: partial.separatorLine, + endLine: ln, + baseLine: partial.baseLine, + oursLabel: partial.oursLabel, + baseLabel: partial.baseLabel, + theirsLabel: theirsLabel || undefined, + oursLines: partial.oursLines, + baseLines: partial.baseLines, + theirsLines: partial.theirsLines, + }); + } + partial = null; + phase = "idle"; + continue; + } + + if (phase === "ours") partial.oursLines.push(line); + else if (phase === "base" && partial.baseLines) partial.baseLines.push(line); + else if (phase === "theirs" && partial.theirsLines) partial.theirsLines.push(line); + } + + return blocks; +} + +/** + * Return the label after a marker prefix when the line is a valid + * column-0 marker, or `null` when it isn't. Strict shape: prefix alone, + * or prefix + single space + label. + */ +function matchMarker(line: string, prefix: string): string | null { + if (!line.startsWith(prefix)) return null; + if (line.length === prefix.length) return ""; + if (line.charCodeAt(prefix.length) !== 32 /* space */) return null; + return line.slice(prefix.length + 1); +} + +/** + * Recorded conflict block keyed by a session-stable id. The history is + * append-only; ids stay valid even after later writes resolve other + * blocks in the same file, so retries don't depend on re-reading. + */ +export interface ConflictEntry extends ConflictBlock { + id: number; + absolutePath: string; + displayPath: string; +} + +/** Per-session log of conflict regions surfaced by `read`. */ +export class ConflictHistory { + #nextId = 1; + #entries = new Map<number, ConflictEntry>(); + + /** + * Register a conflict block. Returns the (possibly pre-existing) entry + * — if the same `absolutePath`+`startLine` was registered before, the + * earlier id is reused so a re-read does not inflate the counter or + * orphan the prior id. The recorded region is overwritten on re-read + * so the splice always reflects the current marker positions on disk. + */ + register(input: Omit<ConflictEntry, "id">): ConflictEntry { + for (const existing of this.#entries.values()) { + if (existing.absolutePath === input.absolutePath && existing.startLine === input.startLine) { + const merged: ConflictEntry = { ...input, id: existing.id }; + this.#entries.set(existing.id, merged); + return merged; + } + } + const id = this.#nextId++; + const entry: ConflictEntry = { ...input, id }; + this.#entries.set(id, entry); + return entry; + } + + get(id: number): ConflictEntry | undefined { + return this.#entries.get(id); + } + + /** Drop a single entry by id. Used after a successful resolve. */ + invalidate(id: number): void { + this.#entries.delete(id); + } + + /** Drop every entry referencing `absolutePath`. Used after a successful resolve. */ + invalidatePath(absolutePath: string): void { + for (const [id, entry] of this.#entries) { + if (entry.absolutePath === absolutePath) { + this.#entries.delete(id); + } + } + } +} + +/** Lazily attach a `ConflictHistory` to the session and return it. */ +export function getConflictHistory(session: ToolSession): ConflictHistory { + if (!session.conflictHistory) session.conflictHistory = new ConflictHistory(); + return session.conflictHistory; +} + +/** Parsed `conflict://<N>` URI. */ +export interface ParsedConflictUri { + id: number; +} + +const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/; + +/** + * Parse a `conflict://<N>` URI. Returns `null` for non-conflict paths; + * throws `ToolError` for a well-formed scheme with an invalid id so the + * agent gets a clear actionable message rather than a confusing "not + * found" later. + */ +export function parseConflictUri(raw: string): ParsedConflictUri | null { + const match = raw.match(CONFLICT_URI_RE); + if (!match) return null; + const tail = match[1]; + if (!/^\d+$/.test(tail)) { + throw new ToolError( + `Invalid conflict URI '${raw}': must be 'conflict://<N>' where N is a positive integer surfaced by a prior \`read\`.`, + ); + } + const id = Number.parseInt(tail, 10); + if (!Number.isFinite(id) || id < 1) { + throw new ToolError(`Invalid conflict URI '${raw}': id must be ≥ 1.`); + } + return { id }; +} + +/** + * Splice the conflict region `[entry.startLine..entry.endLine]` (1-indexed, + * inclusive of every marker and all sides) out of `originalText` and + * replace it with `replacement`. A single trailing newline on + * `replacement` is normalised so the splice rejoins cleanly. + * + * Re-validates that the recorded marker lines still look like markers + * before splicing — if the file has been edited out-of-band and the + * recorded range no longer brackets a conflict, throw rather than + * corrupting the file. + */ +export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string { + const lines = originalText.split("\n"); + const startIdx = entry.startLine - 1; + const endIdx = entry.endLine - 1; + if (startIdx < 0 || endIdx >= lines.length || startIdx > endIdx) { + throw new ToolError( + `Conflict #${entry.id} range [${entry.startLine}..${entry.endLine}] is outside the current file (${lines.length} lines). The file has changed since the conflict was registered — re-read it to pick up the new layout.`, + ); + } + if (matchMarker(lines[startIdx], OURS_PREFIX) === null) { + throw new ToolError( + `Conflict #${entry.id} stale: line ${entry.startLine} of '${entry.displayPath}' no longer starts with '<<<<<<<'. Re-read the file to re-register the conflict.`, + ); + } + if (matchMarker(lines[endIdx], THEIRS_PREFIX) === null) { + throw new ToolError( + `Conflict #${entry.id} stale: line ${entry.endLine} of '${entry.displayPath}' no longer starts with '>>>>>>>'. Re-read the file to re-register the conflict.`, + ); + } + + const trimmed = normalizeTrailingNewline(replacement); + const replacementLines = trimmed.split("\n"); + const next = [...lines.slice(0, startIdx), ...replacementLines, ...lines.slice(endIdx + 1)]; + return next.join("\n"); +} + +function normalizeTrailingNewline(replacement: string): string { + if (replacement.endsWith("\r\n")) return replacement.slice(0, -2); + if (replacement.endsWith("\n")) return replacement.slice(0, -1); + return replacement; +} + +/** + * Expand `@ours` / `@theirs` / `@base` / `@both` line tokens against the + * recorded sections of `entry`. A token only triggers when it is the + * entire content of a line (after CRLF normalisation), so `@ours` inside + * actual code is left alone. Other lines pass through verbatim. + * + * - `@ours` → expands to the recorded `oursLines` (in order). + * - `@theirs` → expands to the recorded `theirsLines` (in order). + * - `@base` → expands to `baseLines`; throws if no base section was + * recorded (i.e. the conflict was 2-way, not diff3). + * - `@both` → expands to `oursLines` then `theirsLines`. + */ +export function expandContentTokens(content: string, entry: ConflictEntry): string { + const inputLines = content.split("\n"); + const out: string[] = []; + for (const rawLine of inputLines) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + switch (line) { + case "@ours": + out.push(...entry.oursLines); + break; + case "@theirs": + out.push(...entry.theirsLines); + break; + case "@base": + if (!entry.baseLines) { + throw new ToolError( + `Conflict #${entry.id} has no base section (2-way merge). \`@base\` is only valid for diff3 conflicts.`, + ); + } + out.push(...entry.baseLines); + break; + case "@both": + out.push(...entry.oursLines, ...entry.theirsLines); + break; + default: + out.push(rawLine); + break; + } + } + return out.join("\n"); +} + +const PREVIEW_SIDE_LINES = 6; + +/** + * Build a compact diff-style footer describing the conflicts registered + * during a read. Designed to be appended after the file content. + * + * Format: + * + * ⚠ N unresolved conflicts detected + * - ours = HEAD + * - theirs = feature/x + * NOTICE: … + * + * ──── #1 L42-48 ──── + * <<< ours + * …ours body… + * === base ≡ ours + * >>> theirs + * …theirs body… + * + * Labels are aggregated once at the top from the first entry that has + * them; when a section body equals another section's body the redundant + * body is collapsed to `≡ <other>`. + */ +export function formatConflictWarning(entries: readonly ConflictEntry[]): string { + if (entries.length === 0) return ""; + const out: string[] = []; + out.push(""); + const word = entries.length === 1 ? "conflict" : "conflicts"; + out.push(`⚠ ${entries.length} unresolved ${word} detected`); + + const oursLabel = pickLabel(entries, e => e.oursLabel); + const theirsLabel = pickLabel(entries, e => e.theirsLabel); + const baseLabel = pickLabel(entries, e => (e.baseLines !== undefined ? e.baseLabel : undefined)); + const anyBase = entries.some(e => e.baseLines !== undefined); + if (oursLabel) out.push(`- ours = ${oursLabel}`); + if (theirsLabel) out.push(`- theirs = ${theirsLabel}`); + if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`); + out.push( + 'NOTICE: Resolve each via `write({ path: "conflict://<N>", content })`; the tool replaces the whole conflict region. Use `@ours` / `@theirs` / `@base` / `@both` as content shorthand for the recorded sections (alone or mixed line-by-line).', + ); + + for (const entry of entries) { + const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`; + out.push(""); + out.push(`──── #${entry.id} ${range} ────`); + + const baseEqualsOurs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.oursLines); + const baseEqualsTheirs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.theirsLines); + const theirsEqualsOurs = sectionsEqual(entry.theirsLines, entry.oursLines); + + out.push("<<< ours"); + appendBody(out, entry.oursLines); + + if (entry.baseLines !== undefined) { + if (baseEqualsOurs) { + out.push("=== base ≡ ours"); + } else if (baseEqualsTheirs) { + out.push("=== base ≡ theirs"); + } else { + out.push("=== base"); + appendBody(out, entry.baseLines); + } + } + + if (theirsEqualsOurs) { + out.push(">>> theirs ≡ ours"); + } else { + out.push(">>> theirs"); + appendBody(out, entry.theirsLines); + } + } + return out.join("\n"); +} + +function pickLabel( + entries: readonly ConflictEntry[], + get: (e: ConflictEntry) => string | undefined, +): string | undefined { + for (const e of entries) { + const label = get(e); + if (label && label.trim().length > 0) return label; + } + return undefined; +} + +function sectionsEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function appendBody(out: string[], section: readonly string[]): void { + if (section.length === 0) { + out.push("(empty)"); + return; + } + const shown = section.slice(0, PREVIEW_SIDE_LINES); + for (const line of shown) out.push(line); + const hidden = section.length - shown.length; + if (hidden > 0) out.push(`… (${hidden} more line${hidden === 1 ? "" : "s"})`); +} diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index c25cccb23..519dfb21f 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -228,6 +228,12 @@ export interface ToolSession { * out-of-band. Lazily initialized by `getFileReadCache`. */ fileReadCache?: import("../edit/file-read-cache").FileReadCache; + /** Per-session log of unresolved git merge conflict regions surfaced by + * `read`. Each entry gets a stable id N referenced by `write conflict://N` + * to splice the recorded region with replacement content. Lazily initialized + * by `getConflictHistory`. */ + conflictHistory?: import("./conflict-detect").ConflictHistory; + /** Queue a hidden message to be injected at the next agent turn. */ queueDeferredMessage?(message: CustomMessage): void; } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index b984159b4..62f330f70 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -33,6 +33,7 @@ import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from " import { convertFileWithMarkit } from "../utils/markit"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader"; +import { formatConflictWarning, getConflictHistory, scanConflictLines } from "./conflict-detect"; import { executeReadUrl, isReadableUrlPath, @@ -455,6 +456,8 @@ export interface ReadToolDetails { * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */ displayContent?: { text: string; startLine: number }; summary?: { lines: number; elidedSpans: number }; + /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */ + conflictCount?: number; } type ReadParams = ReadToolInput; @@ -1517,6 +1520,23 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> { details.displayContent = capturedDisplayContent; } + if (!firstLineExceedsLimit && collectedLines.length > 0) { + const blocks = scanConflictLines(collectedLines, startLineDisplay); + if (blocks.length > 0) { + const history = getConflictHistory(this.session); + const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd); + const entries = blocks.map(block => + history.register({ + absolutePath, + displayPath: displayPathForWarning, + ...block, + }), + ); + outputText += formatConflictWarning(entries); + details.conflictCount = entries.length; + } + } + content = [{ type: "text", text: outputText }]; } } @@ -1763,6 +1783,10 @@ export const readToolRenderer = { if (details?.summary) { title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`; } + if (details?.conflictCount && details.conflictCount > 0) { + const n = details.conflictCount; + title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`; + } let cachedWidth: number | undefined; let cachedLines: string[] | undefined; return { diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 73c8e052b..efe82b0cd 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -16,6 +16,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { parseArchivePathCandidates } from "./archive-reader"; import { assertEditableFile } from "./auto-generated-guard"; +import { + type ConflictEntry, + expandContentTokens, + getConflictHistory, + parseConflictUri, + spliceConflict, +} from "./conflict-detect"; import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation"; import { type OutputMeta, outputMeta } from "./output-meta"; import { formatPathRelativeToCwd } from "./path-utils"; @@ -423,6 +430,65 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails } } + /** + * Resolve a single `conflict://<N>` write by splicing the recorded + * marker region in the registered file with `replacementContent`, + * then routing the new file content through the normal writethrough + * pipeline so LSP format/diagnostics still run. + * + * Entry ids are session-stable: they keep working even after later + * writes resolve other blocks in the same file. The recorded range + * is re-validated on disk before splicing so an out-of-band edit + * surfaces as a clear error instead of corrupting the file. + */ + async #resolveConflict( + entry: ConflictEntry, + replacementContent: string, + stripped: boolean, + signal: AbortSignal | undefined, + context: AgentToolContext | undefined, + ): Promise<AgentToolResult<WriteToolDetails>> { + const absolutePath = entry.absolutePath; + if (!(await fs.exists(absolutePath))) { + throw new ToolError(`Conflict #${entry.id} target '${entry.displayPath}' no longer exists.`); + } + + const expanded = expandContentTokens(replacementContent, entry); + const originalText = await Bun.file(absolutePath).text(); + const newContent = spliceConflict(originalText, entry, expanded); + + const batchRequest = getLspBatchRequest(context?.toolCall); + const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest); + invalidateFsScanAfterWrite(absolutePath); + this.session.fileReadCache?.invalidate(absolutePath); + this.session.conflictHistory?.invalidate(entry.id); + + const range = + entry.startLine === entry.endLine + ? `line ${entry.startLine}` + : `lines ${entry.startLine}\u2013${entry.endLine}`; + let resultText = `Resolved conflict #${entry.id} at ${range} in ${entry.displayPath}.`; + if (stripped) { + resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; + } + + if (!diagnostics) { + return { + content: [{ type: "text", text: resultText }], + details: {}, + }; + } + return { + content: [{ type: "text", text: resultText }], + details: { + diagnostics, + meta: outputMeta() + .diagnostics(diagnostics.summary, diagnostics.messages ?? []) + .get(), + }, + }; + } + async execute( _toolCallId: string, { path, content }: WriteParams, @@ -433,6 +499,16 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails return untilAborted(signal, async () => { // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output const { text: cleanContent, stripped } = stripWriteContent(this.session, content); + const conflictUri = parseConflictUri(path); + if (conflictUri) { + const entry = getConflictHistory(this.session).get(conflictUri.id); + if (!entry) { + throw new ToolError( + `Conflict #${conflictUri.id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`, + ); + } + return this.#resolveConflict(entry, cleanContent, stripped, signal, context); + } const resolvedArchivePath = await this.#resolveArchiveWritePath(path); if (resolvedArchivePath) { enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, { diff --git a/packages/coding-agent/test/tools/conflict-detect.test.ts b/packages/coding-agent/test/tools/conflict-detect.test.ts new file mode 100644 index 000000000..6c7527e1a --- /dev/null +++ b/packages/coding-agent/test/tools/conflict-detect.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "bun:test"; +import { + type ConflictEntry, + ConflictHistory, + expandContentTokens, + formatConflictWarning, + parseConflictUri, + scanConflictLines, + spliceConflict, +} from "@oh-my-pi/pi-coding-agent/tools/conflict-detect"; +import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; + +describe("scanConflictLines", () => { + it("detects a 2-way conflict with correct line numbers and labels", () => { + const lines = [ + "line A", + "<<<<<<< HEAD", + "ours one", + "ours two", + "=======", + "theirs one", + ">>>>>>> feature/x", + "line Z", + ]; + const blocks = scanConflictLines(lines, 1); + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block.startLine).toBe(2); + expect(block.separatorLine).toBe(5); + expect(block.endLine).toBe(7); + expect(block.baseLine).toBeUndefined(); + expect(block.oursLabel).toBe("HEAD"); + expect(block.theirsLabel).toBe("feature/x"); + expect(block.oursLines).toEqual(["ours one", "ours two"]); + expect(block.theirsLines).toEqual(["theirs one"]); + }); + + it("detects a 3-way diff3 conflict with base section", () => { + const blocks = scanConflictLines( + ["<<<<<<< HEAD", "ours", "||||||| merged common ancestor", "base", "=======", "theirs", ">>>>>>> branch"], + 1, + ); + expect(blocks).toHaveLength(1); + expect(blocks[0].baseLine).toBe(3); + expect(blocks[0].baseLabel).toBe("merged common ancestor"); + expect(blocks[0].baseLines).toEqual(["base"]); + expect(blocks[0].oursLines).toEqual(["ours"]); + expect(blocks[0].theirsLines).toEqual(["theirs"]); + }); + + it("offsets line numbers by firstLineNumber", () => { + const blocks = scanConflictLines(["<<<<<<<", "o", "=======", "t", ">>>>>>>"], 100); + expect(blocks).toHaveLength(1); + expect(blocks[0].startLine).toBe(100); + expect(blocks[0].separatorLine).toBe(102); + expect(blocks[0].endLine).toBe(104); + }); + + it("returns multiple blocks in file order", () => { + const blocks = scanConflictLines( + ["<<<<<<< A", "o1", "=======", "t1", ">>>>>>> A", "middle", "<<<<<<< B", "o2", "=======", "t2", ">>>>>>> B"], + 1, + ); + expect(blocks.map(b => b.oursLabel)).toEqual(["A", "B"]); + }); + + it("ignores unclosed openers", () => { + const blocks = scanConflictLines(["<<<<<<< HEAD", "ours", "=======", "theirs"], 1); + expect(blocks).toEqual([]); + }); + + it("ignores mis-shaped or indented marker lookalikes", () => { + const blocks = scanConflictLines( + [" <<<<<<< HEAD", " =======", " >>>>>>> branch", "<<<<<<<x", "========", ">>>>>>>x", "const a = 1;"], + 1, + ); + expect(blocks).toEqual([]); + }); + + it("accepts label-less markers", () => { + const blocks = scanConflictLines(["<<<<<<<", "ours", "=======", "theirs", ">>>>>>>"], 1); + expect(blocks).toHaveLength(1); + expect(blocks[0].oursLabel).toBeUndefined(); + expect(blocks[0].theirsLabel).toBeUndefined(); + }); + + it("treats a re-opened `<<<<<<<` as a fresh block", () => { + const blocks = scanConflictLines( + ["<<<<<<< first", "stale ours", "<<<<<<< second", "good ours", "=======", "good theirs", ">>>>>>> end"], + 1, + ); + expect(blocks).toHaveLength(1); + expect(blocks[0].oursLabel).toBe("second"); + expect(blocks[0].oursLines).toEqual(["good ours"]); + }); +}); + +describe("ConflictHistory", () => { + it("assigns monotonic ids and looks entries up by id", () => { + const history = new ConflictHistory(); + const entry1 = history.register({ + absolutePath: "/abs/a.ts", + displayPath: "a.ts", + startLine: 10, + separatorLine: 12, + endLine: 14, + oursLines: ["o"], + theirsLines: ["t"], + }); + const entry2 = history.register({ + absolutePath: "/abs/b.ts", + displayPath: "b.ts", + startLine: 1, + separatorLine: 3, + endLine: 5, + oursLines: ["o2"], + theirsLines: ["t2"], + }); + expect(entry1.id).toBe(1); + expect(entry2.id).toBe(2); + expect(history.get(1)?.absolutePath).toBe("/abs/a.ts"); + expect(history.get(2)?.absolutePath).toBe("/abs/b.ts"); + expect(history.get(99)).toBeUndefined(); + }); + + it("dedupes registration by absolutePath+startLine and refreshes recorded body", () => { + const history = new ConflictHistory(); + const first = history.register({ + absolutePath: "/abs/a.ts", + displayPath: "a.ts", + startLine: 10, + separatorLine: 12, + endLine: 14, + oursLines: ["old-ours"], + theirsLines: ["old-theirs"], + }); + const second = history.register({ + absolutePath: "/abs/a.ts", + displayPath: "a.ts", + startLine: 10, + separatorLine: 12, + endLine: 16, // file gained 2 lines in the ours section + oursLines: ["new-ours-1", "new-ours-2", "new-ours-3"], + theirsLines: ["new-theirs"], + }); + expect(second.id).toBe(first.id); + expect(history.get(first.id)?.endLine).toBe(16); + expect(history.get(first.id)?.oursLines).toEqual(["new-ours-1", "new-ours-2", "new-ours-3"]); + }); + + it("invalidatePath drops entries scoped to one absolutePath", () => { + const history = new ConflictHistory(); + history.register({ + absolutePath: "/abs/a.ts", + displayPath: "a.ts", + startLine: 1, + separatorLine: 3, + endLine: 5, + oursLines: [], + theirsLines: [], + }); + history.register({ + absolutePath: "/abs/b.ts", + displayPath: "b.ts", + startLine: 1, + separatorLine: 3, + endLine: 5, + oursLines: [], + theirsLines: [], + }); + history.invalidatePath("/abs/a.ts"); + expect(history.get(1)).toBeUndefined(); + expect(history.get(2)).toBeDefined(); + }); +}); + +describe("parseConflictUri", () => { + it("parses well-formed URIs", () => { + expect(parseConflictUri("conflict://1")).toEqual({ id: 1 }); + expect(parseConflictUri("conflict://42")).toEqual({ id: 42 }); + }); + + it("returns null for non-conflict paths", () => { + expect(parseConflictUri("src/foo.ts")).toBeNull(); + expect(parseConflictUri("file:///abs/path")).toBeNull(); + expect(parseConflictUri("conflict://")).toBeNull(); + }); + + it("rejects malformed ids with a ToolError", () => { + expect(() => parseConflictUri("conflict://0")).toThrow(ToolError); + expect(() => parseConflictUri("conflict://-1")).toThrow(ToolError); + expect(() => parseConflictUri("conflict://1.5")).toThrow(ToolError); + expect(() => parseConflictUri("conflict://abc")).toThrow(ToolError); + expect(() => parseConflictUri("conflict://1/extra")).toThrow(ToolError); + }); +}); + +function makeEntry(overrides: Partial<ConflictEntry> = {}): ConflictEntry { + return { + id: 1, + absolutePath: "/abs/a.ts", + displayPath: "a.ts", + startLine: 2, + separatorLine: 4, + endLine: 6, + oursLines: ["o"], + theirsLines: ["t"], + ...overrides, + }; +} + +describe("spliceConflict", () => { + const file = ["before", "<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> feat", "after", ""].join("\n"); + + it("replaces the marker region with the chosen content", () => { + const result = spliceConflict(file, makeEntry(), "resolved\n"); + expect(result).toBe("before\nresolved\nafter\n"); + }); + + it("accepts multi-line replacement", () => { + const result = spliceConflict(file, makeEntry(), "alpha\nbeta\n"); + expect(result).toBe("before\nalpha\nbeta\nafter\n"); + }); + + it("accepts empty replacement", () => { + const result = spliceConflict(file, makeEntry(), ""); + expect(result).toBe("before\n\nafter\n"); + }); + + it("rejects stale ranges when the start marker has been edited away", () => { + const stale = ["before", "// resolved by hand", "after", ""].join("\n"); + expect(() => spliceConflict(stale, makeEntry({ endLine: 2 }), "x\n")).toThrow(ToolError); + }); + + it("rejects ranges past end of file", () => { + expect(() => spliceConflict("short\n", makeEntry({ startLine: 10, endLine: 15 }), "x\n")).toThrow(ToolError); + }); +}); + +describe("formatConflictWarning", () => { + it("emits empty string when no entries", () => { + expect(formatConflictWarning([])).toBe(""); + }); + + it("renders the compact diff-style block with labels aggregated at top", () => { + const entry = makeEntry({ + id: 7, + startLine: 12, + separatorLine: 14, + endLine: 16, + oursLabel: "HEAD", + theirsLabel: "feature/x", + oursLines: ["a", "b"], + theirsLines: ["c"], + }); + const text = formatConflictWarning([entry]); + expect(text).toContain("⚠ 1 unresolved conflict detected"); + expect(text).toContain("- ours = HEAD"); + expect(text).toContain("- theirs = feature/x"); + expect(text).toContain("──── #7 L12-16 ────"); + expect(text).toContain("<<< ours"); + expect(text).toContain("\na\n"); + expect(text).toContain("\nb\n"); + expect(text).toContain(">>> theirs"); + expect(text).toContain("\nc"); + // NOTICE line with shorthand tokens. + expect(text).toContain('NOTICE: Resolve each via `write({ path: "conflict://<N>", content })`'); + expect(text).toContain("@ours"); + expect(text).toContain("@theirs"); + // No per-block invocation; the old verbose header is gone. + expect(text).not.toContain('write({ path: "conflict://7"'); + expect(text).not.toContain("--- ours"); + expect(text).not.toContain("[conflict #7]"); + }); + + it("pluralizes the summary count and emits one block per entry", () => { + const e1 = makeEntry({ id: 1 }); + const e2 = makeEntry({ id: 2, startLine: 20, separatorLine: 22, endLine: 24 }); + const text = formatConflictWarning([e1, e2]); + expect(text).toContain("⚠ 2 unresolved conflicts detected"); + expect(text).toContain("──── #1 L2-6 ────"); + expect(text).toContain("──── #2 L20-24 ────"); + }); + + it("collapses base ≡ ours by skipping the redundant body", () => { + const entry = makeEntry({ + id: 3, + baseLines: ["o"], + oursLines: ["o"], + theirsLines: ["t"], + baseLabel: "ancestor", + }); + const text = formatConflictWarning([entry]); + expect(text).toContain("=== base ≡ ours"); + // Base body should not be duplicated. + const baseHeaderIdx = text.indexOf("=== base ≡ ours"); + const theirsHeaderIdx = text.indexOf(">>> theirs"); + expect(theirsHeaderIdx).toBeGreaterThan(baseHeaderIdx); + const between = text.slice(baseHeaderIdx + "=== base ≡ ours".length, theirsHeaderIdx).trim(); + expect(between).toBe(""); + }); + + it("collapses base ≡ theirs the same way", () => { + const entry = makeEntry({ + id: 4, + baseLines: ["t"], + oursLines: ["o"], + theirsLines: ["t"], + }); + const text = formatConflictWarning([entry]); + expect(text).toContain("=== base ≡ theirs"); + }); + + it("prints the base body when base differs from both sides", () => { + const entry = makeEntry({ + id: 5, + baseLines: ["b"], + oursLines: ["o"], + theirsLines: ["t"], + baseLabel: "common ancestor", + }); + const text = formatConflictWarning([entry]); + expect(text).toContain("- base = common ancestor"); + expect(text).toContain("=== base"); + expect(text).not.toContain("=== base ≡"); + expect(text).toContain("\nb\n"); + }); + + it("omits the ours/theirs label lines when no entry has labels", () => { + const entry = makeEntry({ oursLabel: undefined, theirsLabel: undefined }); + const text = formatConflictWarning([entry]); + expect(text).not.toContain("- ours ="); + expect(text).not.toContain("- theirs ="); + }); + + it("caps the body preview at PREVIEW_SIDE_LINES with a `… N more lines` footer", () => { + const ours = Array.from({ length: 20 }, (_v, i) => `o${i}`); + const entry = makeEntry({ id: 6, oursLines: ours, theirsLines: ["t"] }); + const text = formatConflictWarning([entry]); + expect(text).toContain("\no0\n"); + expect(text).toContain("\no5\n"); + // 6 lines shown, so 14 remain. + expect(text).toContain("… (14 more lines)"); + // Lines past the cap are dropped from the preview. + expect(text).not.toContain("\no6\n"); + }); +}); + +describe("expandContentTokens", () => { + const entry = makeEntry({ + oursLines: ["o1", "o2"], + theirsLines: ["t1"], + }); + + it("returns content unchanged when no tokens are present", () => { + expect(expandContentTokens("hand-written\nline\n", entry)).toBe("hand-written\nline\n"); + }); + + it("expands a bare `@ours` token", () => { + expect(expandContentTokens("@ours", entry)).toBe("o1\no2"); + }); + + it("expands `@theirs` and `@both` line tokens", () => { + expect(expandContentTokens("@theirs", entry)).toBe("t1"); + expect(expandContentTokens("@both", entry)).toBe("o1\no2\nt1"); + }); + + it("mixes tokens with literal lines", () => { + expect(expandContentTokens("// keep both\n@ours\n@theirs", entry)).toBe("// keep both\no1\no2\nt1"); + }); + + it("expands `@base` only when the entry has a base section", () => { + const withBase = makeEntry({ baseLines: ["b1"], oursLines: ["o"], theirsLines: ["t"] }); + expect(expandContentTokens("@base", withBase)).toBe("b1"); + expect(() => expandContentTokens("@base", entry)).toThrow(ToolError); + }); + + it("leaves `@ours` inside a real code line literal (token must be the whole line)", () => { + expect(expandContentTokens("const x = '@ours';", entry)).toBe("const x = '@ours';"); + }); + + it("handles CRLF input lines", () => { + expect(expandContentTokens("@ours\r\n@theirs", entry)).toBe("o1\no2\nt1"); + }); +}); diff --git a/packages/coding-agent/test/tools/conflict-integration.test.ts b/packages/coding-agent/test/tools/conflict-integration.test.ts new file mode 100644 index 000000000..3e3e28fba --- /dev/null +++ b/packages/coding-agent/test/tools/conflict-integration.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { ConflictHistory } from "@oh-my-pi/pi-coding-agent/tools/conflict-detect"; + +function createTestSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession { + return { + cwd, + hasUI: false, + enableLsp: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + ...overrides, + } as unknown as ToolSession; +} + +function getText(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter(entry => entry.type === "text") + .map(entry => entry.text ?? "") + .join("\n"); +} + +async function getTool(session: ToolSession, name: "read" | "write") { + const tools = await createTools(session); + const tool = tools.find(entry => entry.name === name); + if (!tool) throw new Error(`Missing ${name} tool`); + return tool; +} + +const TWO_WAY = ["line 1", "<<<<<<< HEAD", "oldApi(x)", "=======", "newApi(x)", ">>>>>>> feature/x", "line N", ""].join( + "\n", +); + +const THREE_WAY = [ + "head", + "<<<<<<< HEAD", + "ours body", + "||||||| common ancestor", + "base body", + "=======", + "theirs body", + ">>>>>>> feat", + "tail", + "", +].join("\n"); + +const TWO_BLOCKS = [ + "<<<<<<< A", + "a-ours", + "=======", + "a-theirs", + ">>>>>>> A", + "middle", + "<<<<<<< B", + "b-ours", + "=======", + "b-theirs", + ">>>>>>> B", + "tail", + "", +].join("\n"); + +describe("read surfaces conflicts as a warning footer", () => { + let tempDir: string; + + beforeAll(async () => { + _resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "conflict-int-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("returns file content and appends a conflict warning with id 1", async () => { + const filePath = path.join(tempDir, "foo.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + + const result = await read.execute("read-foo", { path: "foo.ts" }); + const text = getText(result); + // Content is still returned. + expect(text).toContain("<<<<<<< HEAD"); + expect(text).toContain("oldApi(x)"); + expect(text).toContain(">>>>>>> feature/x"); + // Warning footer is appended. + expect(text).toContain("⚠"); + expect(text).toContain("⚠ 1 unresolved conflict detected"); + expect(text).toContain("- ours = HEAD"); + expect(text).toContain("- theirs = feature/x"); + expect(text).toContain("──── #1 L2-6 ────"); + expect(text).toContain("<<< ours"); + expect(text).toContain(">>> theirs"); + expect(text).toContain('NOTICE: Resolve each via `write({ path: "conflict://<N>", content })`'); + expect(text).toContain("@ours"); + // Registered on session. + const history = session.conflictHistory; + expect(history).toBeInstanceOf(ConflictHistory); + expect(history?.get(1)?.absolutePath).toBe(filePath); + }); + + it("registers diff3 conflicts with base section", async () => { + const filePath = path.join(tempDir, "three.ts"); + await Bun.write(filePath, THREE_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + + const result = await read.execute("read-three", { path: "three.ts" }); + const text = getText(result); + expect(text).toContain("- base = common ancestor"); + expect(text).toContain("=== base"); + expect(session.conflictHistory?.get(1)?.baseLines).toEqual(["base body"]); + }); + + it("registers each block with its own id when several appear in one window", async () => { + const filePath = path.join(tempDir, "two-blocks.ts"); + await Bun.write(filePath, TWO_BLOCKS); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + + const result = await read.execute("read-two", { path: "two-blocks.ts" }); + const text = getText(result); + expect(text).toContain("──── #1 L1-5 ────"); + expect(text).toContain("──── #2 L7-11 ────"); + expect(session.conflictHistory?.get(1)?.oursLines).toEqual(["a-ours"]); + expect(session.conflictHistory?.get(2)?.oursLines).toEqual(["b-ours"]); + }); + + it("emits no warning on clean files and does not touch the history", async () => { + const filePath = path.join(tempDir, "clean.ts"); + await Bun.write(filePath, "const a = 1;\nconst b = 2;\n"); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + + const result = await read.execute("read-clean", { path: "clean.ts" }); + const text = getText(result); + expect(text).toContain("const a = 1;"); + expect(text).not.toContain("conflict://"); + expect(text).not.toContain("⚠"); + expect(session.conflictHistory?.get(1)).toBeUndefined(); + }); + + it("re-reading the same file reuses the existing id rather than inflating", async () => { + const filePath = path.join(tempDir, "stable.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + + await read.execute("read-stable-1", { path: "stable.ts" }); + await read.execute("read-stable-2", { path: "stable.ts" }); + expect(session.conflictHistory?.get(1)).toBeDefined(); + expect(session.conflictHistory?.get(2)).toBeUndefined(); + }); +}); + +describe("write resolves conflicts via conflict://N", () => { + let tempDir: string; + + beforeAll(async () => { + _resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "conflict-int-write-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("splices the registered region with the supplied content", async () => { + const filePath = path.join(tempDir, "foo.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-foo", { path: "foo.ts" }); + const result = await write.execute("write-foo", { + path: "conflict://1", + content: "newApi(x);\n", + }); + + expect(getText(result)).toContain("Resolved conflict #1"); + const after = await Bun.file(filePath).text(); + expect(after).toBe("line 1\nnewApi(x);\nline N\n"); + // History is invalidated after resolve so the id no longer works. + expect(session.conflictHistory?.get(1)).toBeUndefined(); + }); + + it("can resolve two blocks in the same file by id, in either order", async () => { + const filePath = path.join(tempDir, "two.ts"); + await Bun.write(filePath, TWO_BLOCKS); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-two", { path: "two.ts" }); + + // Resolve #2 (block B) first to confirm out-of-order works. + await write.execute("write-two-2", { + path: "conflict://2", + content: "B-resolved\n", + }); + // #1 is still registered and points at unchanged lines (block B sits + // below block A so the splice does not move A). No re-read needed. + await write.execute("write-two-1", { + path: "conflict://1", + content: "A-resolved\n", + }); + + const after = await Bun.file(filePath).text(); + expect(after).toBe("A-resolved\nmiddle\nB-resolved\ntail\n"); + }); + + it("accepts `@ours`/`@theirs`/`@both` content tokens as shorthand", async () => { + const filePath = path.join(tempDir, "tokens.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-tokens", { path: "tokens.ts" }); + await write.execute("write-tokens", { path: "conflict://1", content: "@theirs" }); + + const after = await Bun.file(filePath).text(); + expect(after).toBe("line 1\nnewApi(x)\nline N\n"); + }); + + it("expands `@both` to ours then theirs without re-typing either side", async () => { + const filePath = path.join(tempDir, "both.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-both", { path: "both.ts" }); + await write.execute("write-both", { path: "conflict://1", content: "@both" }); + + const after = await Bun.file(filePath).text(); + expect(after).toBe("line 1\noldApi(x)\nnewApi(x)\nline N\n"); + }); + + it("rejects `@base` for a 2-way conflict with a clear error", async () => { + const filePath = path.join(tempDir, "nobase.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-nobase", { path: "nobase.ts" }); + const promise = write.execute("write-nobase", { path: "conflict://1", content: "@base" }); + await expect(promise).rejects.toThrow(/no base section/); + // File untouched. + expect(await Bun.file(filePath).text()).toBe(TWO_WAY); + }); + + it("errors clearly when the id is unknown", async () => { + const filePath = path.join(tempDir, "nope.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const write = await getTool(session, "write"); + + const promise = write.execute("write-nope", { + path: "conflict://99", + content: "x\n", + }); + await expect(promise).rejects.toThrow(/Conflict #99 not found/); + // File untouched. + expect(await Bun.file(filePath).text()).toBe(TWO_WAY); + }); + + it("errors clearly when the URI itself is malformed", async () => { + const session = createTestSession(tempDir); + const write = await getTool(session, "write"); + + await expect(write.execute("write-bad-zero", { path: "conflict://0", content: "x" })).rejects.toThrow( + /Invalid conflict URI/, + ); + await expect(write.execute("write-bad-neg", { path: "conflict://-1", content: "x" })).rejects.toThrow( + /Invalid conflict URI/, + ); + await expect(write.execute("write-bad-frac", { path: "conflict://1.5", content: "x" })).rejects.toThrow( + /Invalid conflict URI/, + ); + }); + + it("rejects stale resolutions when the file changed out of band", async () => { + const filePath = path.join(tempDir, "stale.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-stale", { path: "stale.ts" }); + // User resolves the conflict by hand outside the agent. + await Bun.write(filePath, "line 1\nresolved by hand\nline N\n"); + + const promise = write.execute("write-stale", { + path: "conflict://1", + content: "agent-pick\n", + }); + await expect(promise).rejects.toThrow(/stale|outside the current file|no longer/i); + // File untouched by the failed write. + expect(await Bun.file(filePath).text()).toBe("line 1\nresolved by hand\nline N\n"); + }); + + it("strips hashline display prefixes from replacement content when hashline mode is active", async () => { + const filePath = path.join(tempDir, "hashed.ts"); + await Bun.write(filePath, TWO_WAY); + const session = createTestSession(tempDir, { + settings: Settings.isolated({ readHashLines: true }), + }); + const read = await getTool(session, "read"); + const write = await getTool(session, "write"); + + await read.execute("read-hashed", { path: "hashed.ts" }); + const result = await write.execute("write-hashed", { + path: "conflict://1", + content: "42xy|cleanline\n", + }); + expect(getText(result)).toContain("auto-stripped hashline display prefixes"); + const after = await Bun.file(filePath).text(); + expect(after).toBe("line 1\ncleanline\nline N\n"); + }); +});