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.
This commit is contained in:
can1357
2026-05-12 10:52:52 +02:00
parent 093c13877a
commit 478db5183a
8 changed files with 1275 additions and 0 deletions
+8
View File
@@ -1,22 +1,30 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed the `eval` tool input format to a single-line `*** Cell <lang>:"<title>" [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
@@ -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;
}
@@ -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"})`);
}
+6
View File
@@ -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;
}
+24
View File
@@ -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 {
+76
View File
@@ -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, {
@@ -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");
});
});
@@ -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");
});
});