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:
@@ -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"})`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user