swarm-ui: an editor for an agent's AGENTS.md that saves as a diff
The instructions row of the agent detail panel gets an edit button that opens the agent's AGENTS.md, as the harness last published it, in a text box. Save sends a unified diff against the revision that was loaded; when the agent changed the file meanwhile the editor keeps the operator's text, shows the agent's version and offers to save over it or take it. An unsaved edit survives closing the dialog. The diff is built in the browser (LCS over lines, three lines of context, git's no-newline marker) and was checked against git apply on 600 random before/after pairs.
This commit is contained in:
parent
bff13c8c68
commit
b5d2e310ba
7 changed files with 664 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import type { AgentActions } from "../roster/AgentActions.js";
|
||||
import { AgentInference } from "./AgentInference.js";
|
||||
import { AgentInstructions } from "./AgentInstructions.js";
|
||||
import { AgentLimits } from "./AgentLimits.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
|
|
@ -103,6 +104,10 @@ export function AgentDetail({
|
|||
<dd>
|
||||
<AgentInference key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>instructions</dt>
|
||||
<dd>
|
||||
<AgentInstructions key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>resource caps</dt>
|
||||
<dd>
|
||||
<AgentLimits key={row.name} agent={row.name} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
/* <AgentInstructions> — the AGENTS.md editor in a dialog. */
|
||||
.ui-agent-instructions-body,
|
||||
.ui-agent-instructions-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75em;
|
||||
}
|
||||
.ui-agent-instructions-body {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.ui-agent-instructions-text textarea,
|
||||
.ui-agent-instructions-theirs {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ui-agent-instructions-theirs {
|
||||
margin: 0;
|
||||
max-height: 16em;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
padding: 0.5em;
|
||||
border: 1px solid var(--border, currentColor);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ui-agent-instructions-status {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-instructions-stale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
margin: 0;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ui-agent-instructions-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
// <AgentInstructions> — an editor for an agent's AGENTS.md, in a dialog opened
|
||||
// from the agent's detail panel.
|
||||
//
|
||||
// It loads the copy the agent's harness last published
|
||||
// (`GET /api/agents/{name}/agents-md`, so a stopped agent shows the text it
|
||||
// left) into a plain text box. Save sends a unified diff against the revision
|
||||
// that was loaded (`PUT`), and the agent applies it with git to the file it
|
||||
// really reads. The agent may have edited the file in the meantime; then the
|
||||
// swarm refuses with 409 and the editor keeps the operator's text, shows the
|
||||
// agent's version beside it, and offers the two ways on: save over it, or take
|
||||
// the agent's version.
|
||||
//
|
||||
// Closing the dialog does not lose an unsaved edit: the wrapper keeps it, and
|
||||
// the next open starts from it (and notices if the agent moved on).
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||
import {
|
||||
editBody,
|
||||
instructionsUrl,
|
||||
parseInstructions,
|
||||
STALE_STATUS,
|
||||
type Instructions,
|
||||
} from "./instructions.js";
|
||||
import "./AgentInstructions.css";
|
||||
|
||||
// An edit that was not saved: the text, and the version it was made on.
|
||||
interface Unsaved {
|
||||
base: Instructions;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function AgentInstructions({ agent }: { agent: string }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const unsaved = useRef<Unsaved | null>(null);
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="edit"
|
||||
title={`read and change ${agent}'s AGENTS.md`}
|
||||
onClick={() => setEditing(true)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing}
|
||||
onClose={() => setEditing(false)}
|
||||
label={`edit ${agent}'s AGENTS.md`}
|
||||
plain
|
||||
>
|
||||
{/* Remounted per open, so it starts from the kept edit or a fresh read. */}
|
||||
{editing ? (
|
||||
<InstructionsForm
|
||||
agent={agent}
|
||||
kept={unsaved.current}
|
||||
onKeep={(u) => {
|
||||
unsaved.current = u;
|
||||
}}
|
||||
onClose={() => setEditing(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Load =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; problem: ProblemDetails }
|
||||
| { status: "ready" };
|
||||
|
||||
async function fetchInstructions(agent: string): Promise<Instructions> {
|
||||
const r = await fetch(instructionsUrl(agent));
|
||||
if (!r.ok) throw await readApiError(r);
|
||||
const parsed = parseInstructions(await r.json());
|
||||
if (parsed === null) {
|
||||
throw { detail: "the swarm's answer was not an AGENTS.md" };
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function asProblem(e: unknown): ProblemDetails {
|
||||
if (typeof e === "object" && e !== null) return e as ProblemDetails;
|
||||
return { detail: String(e) };
|
||||
}
|
||||
|
||||
function InstructionsForm({
|
||||
agent,
|
||||
kept,
|
||||
onKeep,
|
||||
onClose,
|
||||
}: {
|
||||
agent: string;
|
||||
kept: Unsaved | null;
|
||||
onKeep: (u: Unsaved | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [load, setLoad] = useState<Load>({ status: "loading" });
|
||||
// The version the draft is made against, and the draft.
|
||||
const [base, setBase] = useState<Instructions | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
// The agent's version when it has moved past `base`: set when a save was
|
||||
// refused as stale, or when the kept edit is older than the file.
|
||||
const [theirs, setTheirs] = useState<Instructions | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchInstructions(agent)
|
||||
.then((current) => {
|
||||
if (cancelled) return;
|
||||
if (kept === null) {
|
||||
setBase(current);
|
||||
setDraft(current.content);
|
||||
} else {
|
||||
setBase(kept.base);
|
||||
setDraft(kept.text);
|
||||
if (current.revision !== kept.base.revision) setTheirs(current);
|
||||
}
|
||||
setLoad({ status: "ready" });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setLoad({ status: "error", problem: asProblem(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `kept` is only the starting point, read once when the form mounts.
|
||||
}, [agent]);
|
||||
|
||||
const dirty = base !== null && draft !== base.content;
|
||||
useEffect(() => {
|
||||
if (base !== null) onKeep(dirty ? { base, text: draft } : null);
|
||||
}, [base, draft, dirty]);
|
||||
|
||||
async function save(e: Event) {
|
||||
e.preventDefault();
|
||||
if (base === null) return;
|
||||
const body = editBody(base, draft);
|
||||
if (body === null) return;
|
||||
setBusy(true);
|
||||
setProblem(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const r = await fetch(instructionsUrl(agent), {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.status === STALE_STATUS) {
|
||||
// The agent changed the file: keep the draft, fetch what it is now.
|
||||
setTheirs(await fetchInstructions(agent));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
setProblem(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const answer = (await r.json()) as { revision?: unknown };
|
||||
if (typeof answer.revision !== "string") {
|
||||
setProblem({ detail: "the swarm's answer named no revision" });
|
||||
return;
|
||||
}
|
||||
setBase({ content: draft, revision: answer.revision });
|
||||
setSaved(true);
|
||||
} catch (err: unknown) {
|
||||
setProblem(asProblem(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function keepMine() {
|
||||
if (theirs === null) return;
|
||||
setBase(theirs);
|
||||
setTheirs(null);
|
||||
}
|
||||
|
||||
function takeTheirs() {
|
||||
if (theirs === null) return;
|
||||
setBase(theirs);
|
||||
setDraft(theirs.content);
|
||||
setTheirs(null);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
setLoad({ status: "loading" });
|
||||
fetchInstructions(agent)
|
||||
.then((current) => {
|
||||
setBase(current);
|
||||
setDraft(current.content);
|
||||
setTheirs(null);
|
||||
setProblem(null);
|
||||
setLoad({ status: "ready" });
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
setLoad({ status: "error", problem: asProblem(e) }),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={`AGENTS.md — ${agent}`} icon="📜" onClose={onClose}>
|
||||
<p>
|
||||
The instructions {agent} reads. This is the copy the agent last
|
||||
published, so a stopped agent still shows. Saving needs the agent
|
||||
running: it applies your change to its own file, and refuses when it
|
||||
edited the file since you opened this.
|
||||
</p>
|
||||
<div class="ui-agent-instructions-body">
|
||||
<p
|
||||
class="ui-agent-instructions-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{load.status === "loading"
|
||||
? "loading…"
|
||||
: busy
|
||||
? "saving…"
|
||||
: saved
|
||||
? "saved"
|
||||
: ""}
|
||||
</p>
|
||||
{load.status === "error" ? (
|
||||
<>
|
||||
<ApiErrorPanel
|
||||
context="loading AGENTS.md failed"
|
||||
problem={load.problem}
|
||||
/>
|
||||
<div class="ui-agent-instructions-actions">
|
||||
<Button onClick={reload}>try again</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{load.status === "ready" ? (
|
||||
<form class="ui-agent-instructions-form" onSubmit={save}>
|
||||
<div class="ui-agent-instructions-text">
|
||||
<TextAreaField
|
||||
id={`agents-md-${agent}`}
|
||||
label="AGENTS.md"
|
||||
value={draft}
|
||||
rows={16}
|
||||
onInput={(text) => {
|
||||
setDraft(text);
|
||||
setSaved(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{theirs !== null ? (
|
||||
<section class="ui-agent-instructions-stale" role="alert">
|
||||
<p>
|
||||
{agent} changed AGENTS.md since you started. Your text is kept
|
||||
above. Here is the file now:
|
||||
</p>
|
||||
<pre class="ui-agent-instructions-theirs" tabIndex={0}>
|
||||
{theirs.content === "" ? "(empty)" : theirs.content}
|
||||
</pre>
|
||||
<div class="ui-agent-instructions-actions">
|
||||
<Button onClick={keepMine}>
|
||||
keep my text, replace {agent}'s changes
|
||||
</Button>
|
||||
<Button onClick={takeTheirs}>use {agent}'s version</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||
) : null}
|
||||
<div class="ui-agent-instructions-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={busy || !dirty || theirs !== null}
|
||||
>
|
||||
{busy ? "saving…" : "save"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => {
|
||||
if (base !== null) setDraft(base.content);
|
||||
setSaved(false);
|
||||
}}
|
||||
>
|
||||
undo my changes
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (
|
||||
!dirty ||
|
||||
window.confirm(
|
||||
"Drop your unsaved changes and load the agent's file?",
|
||||
)
|
||||
)
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
reload from {agent}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
44
frontend/packages/swarm-ui/src/agent/detail/instructions.ts
Normal file
44
frontend/packages/swarm-ui/src/agent/detail/instructions.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// What the AGENTS.md editor sends and reads. The routes are
|
||||
// `GET` and `PUT /api/agents/{name}/agents-md` (swarm-controller-api's
|
||||
// `AgentsMdView`, `AgentsMdEdit` and `AgentsMdSaved`).
|
||||
import { unifiedDiff } from "./unified-diff.js";
|
||||
|
||||
/** What `GET` answers: the text as the agent last published it, and its id. */
|
||||
export interface Instructions {
|
||||
content: string;
|
||||
/** The git blob id of `content`, which an edit names as its base. */
|
||||
revision: string;
|
||||
}
|
||||
|
||||
/** The body of a `PUT`. */
|
||||
export interface InstructionsEdit {
|
||||
base_revision: string;
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export function instructionsUrl(agent: string): string {
|
||||
return `/api/agents/${encodeURIComponent(agent)}/agents-md`;
|
||||
}
|
||||
|
||||
/** An answer of the wrong shape is no answer: the editor shows an error. */
|
||||
export function parseInstructions(json: unknown): Instructions | null {
|
||||
if (typeof json !== "object" || json === null) return null;
|
||||
const { content, revision } = json as Record<string, unknown>;
|
||||
if (typeof content !== "string" || typeof revision !== "string") return null;
|
||||
return { content, revision };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `PUT` body that turns `base` into `draft`, or `null` when they are the
|
||||
* same text and there is nothing to save.
|
||||
*/
|
||||
export function editBody(
|
||||
base: Instructions,
|
||||
draft: string,
|
||||
): InstructionsEdit | null {
|
||||
const diff = unifiedDiff(base.content, draft);
|
||||
return diff === "" ? null : { base_revision: base.revision, diff };
|
||||
}
|
||||
|
||||
/** The status the swarm answers a `PUT` with when the file moved on. */
|
||||
export const STALE_STATUS = 409;
|
||||
163
frontend/packages/swarm-ui/src/agent/detail/unified-diff.ts
Normal file
163
frontend/packages/swarm-ui/src/agent/detail/unified-diff.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// A unified diff of one text against another, in the form `git diff` writes
|
||||
// and `git apply` reads: `--- a/<name>`, `+++ b/<name>`, then `@@` hunks with
|
||||
// three lines of context. The AGENTS.md editor sends one of these to the
|
||||
// swarm, which applies it with git on the agent's own copy of the file.
|
||||
//
|
||||
// Lines are compared with their line ending, so a changed final newline is a
|
||||
// change. A last line with no newline is followed by git's
|
||||
// `\ No newline at end of file` marker, as `git diff` writes it.
|
||||
|
||||
const CONTEXT = 3;
|
||||
|
||||
// The middle of two texts that differ in more than this many line pairs is
|
||||
// not diffed line by line (the table would be too large); it becomes one
|
||||
// replaced block, which is still a valid, only coarser, diff.
|
||||
const MAX_CELLS = 4_000_000;
|
||||
|
||||
interface Line {
|
||||
text: string;
|
||||
/** Whether a newline ends the line. Only a text's last line may lack one. */
|
||||
eol: boolean;
|
||||
}
|
||||
|
||||
type Op = { kind: " " | "-" | "+"; line: Line };
|
||||
|
||||
function splitLines(text: string): Line[] {
|
||||
if (text === "") return [];
|
||||
const parts = text.split("\n");
|
||||
const lines: Line[] = parts.map((t) => ({ text: t, eol: true }));
|
||||
// `split` leaves a trailing "" when the text ends with a newline.
|
||||
if (parts[parts.length - 1] === "") lines.pop();
|
||||
else lines[lines.length - 1].eol = false;
|
||||
return lines;
|
||||
}
|
||||
|
||||
function same(a: Line, b: Line): boolean {
|
||||
return a.text === b.text && a.eol === b.eol;
|
||||
}
|
||||
|
||||
// The edit script turning `a` into `b`: the shared head and tail are kept,
|
||||
// and the middle is diffed by longest common subsequence.
|
||||
function script(a: Line[], b: Line[]): Op[] {
|
||||
let head = 0;
|
||||
while (head < a.length && head < b.length && same(a[head], b[head])) head++;
|
||||
let tail = 0;
|
||||
while (
|
||||
tail < a.length - head &&
|
||||
tail < b.length - head &&
|
||||
same(a[a.length - 1 - tail], b[b.length - 1 - tail])
|
||||
) {
|
||||
tail++;
|
||||
}
|
||||
const midA = a.slice(head, a.length - tail);
|
||||
const midB = b.slice(head, b.length - tail);
|
||||
|
||||
const ops: Op[] = a.slice(0, head).map((line) => ({ kind: " ", line }));
|
||||
ops.push(...middle(midA, midB));
|
||||
ops.push(
|
||||
...a.slice(a.length - tail).map((line): Op => ({ kind: " ", line })),
|
||||
);
|
||||
return ops;
|
||||
}
|
||||
|
||||
function middle(a: Line[], b: Line[]): Op[] {
|
||||
if (a.length === 0) return b.map((line) => ({ kind: "+", line }));
|
||||
if (b.length === 0) return a.map((line) => ({ kind: "-", line }));
|
||||
if ((a.length + 1) * (b.length + 1) > MAX_CELLS) {
|
||||
return [
|
||||
...a.map((line): Op => ({ kind: "-", line })),
|
||||
...b.map((line): Op => ({ kind: "+", line })),
|
||||
];
|
||||
}
|
||||
|
||||
// lcs[i][j]: length of the longest common subsequence of a[i..] and b[j..].
|
||||
const w = b.length + 1;
|
||||
const lcs = new Uint32Array((a.length + 1) * w);
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
for (let j = b.length - 1; j >= 0; j--) {
|
||||
lcs[i * w + j] = same(a[i], b[j])
|
||||
? lcs[(i + 1) * w + j + 1] + 1
|
||||
: Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (same(a[i], b[j])) {
|
||||
ops.push({ kind: " ", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[(i + 1) * w + j] >= lcs[i * w + j + 1]) {
|
||||
ops.push({ kind: "-", line: a[i++] });
|
||||
} else {
|
||||
ops.push({ kind: "+", line: b[j++] });
|
||||
}
|
||||
}
|
||||
while (i < a.length) ops.push({ kind: "-", line: a[i++] });
|
||||
while (j < b.length) ops.push({ kind: "+", line: b[j++] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
function render(op: Op): string {
|
||||
const body = `${op.kind}${op.line.text}\n`;
|
||||
return op.line.eol ? body : `${body}\\ No newline at end of file\n`;
|
||||
}
|
||||
|
||||
// `start,count` as a hunk header writes it: a count of 1 is left off, and an
|
||||
// empty range starts one line earlier than it would otherwise.
|
||||
function range(start: number, count: number): string {
|
||||
if (count === 1) return String(start);
|
||||
return `${count === 0 ? start - 1 : start},${count}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff turning `before` into `after` for the file `name`, or `""` when
|
||||
* they are the same text.
|
||||
*/
|
||||
export function unifiedDiff(
|
||||
before: string,
|
||||
after: string,
|
||||
name = "AGENTS.md",
|
||||
): string {
|
||||
if (before === after) return "";
|
||||
const ops = script(splitLines(before), splitLines(after));
|
||||
|
||||
// Hunks: each change with CONTEXT lines either side, merged when the
|
||||
// context of two changes would overlap.
|
||||
const changed: number[] = [];
|
||||
ops.forEach((op, i) => {
|
||||
if (op.kind !== " ") changed.push(i);
|
||||
});
|
||||
const spans: [number, number][] = [];
|
||||
for (const i of changed) {
|
||||
const from = Math.max(0, i - CONTEXT);
|
||||
const to = Math.min(ops.length, i + CONTEXT + 1);
|
||||
const last = spans[spans.length - 1];
|
||||
if (last && from <= last[1]) last[1] = Math.max(last[1], to);
|
||||
else spans.push([from, to]);
|
||||
}
|
||||
|
||||
// Line numbers (1-based) of the first line of each op in either text.
|
||||
const oldAt: number[] = [];
|
||||
const newAt: number[] = [];
|
||||
let o = 1;
|
||||
let n = 1;
|
||||
for (const op of ops) {
|
||||
oldAt.push(o);
|
||||
newAt.push(n);
|
||||
if (op.kind !== "+") o++;
|
||||
if (op.kind !== "-") n++;
|
||||
}
|
||||
|
||||
let out = `--- a/${name}\n+++ b/${name}\n`;
|
||||
for (const [from, to] of spans) {
|
||||
const slice = ops.slice(from, to);
|
||||
const oldCount = slice.filter((op) => op.kind !== "+").length;
|
||||
const newCount = slice.filter((op) => op.kind !== "-").length;
|
||||
out += `@@ -${range(oldAt[from], oldCount)} +${range(newAt[from], newCount)} @@\n`;
|
||||
out += slice.map(render).join("");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
79
frontend/packages/swarm-ui/test/unified-diff.test.mjs
Normal file
79
frontend/packages/swarm-ui/test/unified-diff.test.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// The AGENTS.md editor's diff builder. Run by `npm test`, which Node runs with
|
||||
// its built-in test runner and type stripping, so the `.ts` module is
|
||||
// imported as it is. The expected strings are what `git diff` writes for the
|
||||
// same two texts, and `git apply` reads back.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { unifiedDiff } from "../src/agent/detail/unified-diff.ts";
|
||||
|
||||
test("the same text has no diff", () => {
|
||||
assert.equal(unifiedDiff("a\nb\n", "a\nb\n"), "");
|
||||
assert.equal(unifiedDiff("", ""), "");
|
||||
});
|
||||
|
||||
test("a changed middle line carries three lines of context", () => {
|
||||
const before = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
|
||||
const after = "1\n2\n3\n4\nfive\n6\n7\n8\n9\n";
|
||||
assert.equal(
|
||||
unifiedDiff(before, after),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n" +
|
||||
"@@ -2,7 +2,7 @@\n 2\n 3\n 4\n-5\n+five\n 6\n 7\n 8\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("two far apart changes make two hunks, near ones merge", () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `l${i + 1}`);
|
||||
const far = [...lines];
|
||||
far[1] = "X";
|
||||
far[17] = "Y";
|
||||
const farDiff = unifiedDiff(lines.join("\n") + "\n", far.join("\n") + "\n");
|
||||
assert.equal(farDiff.match(/^@@/gm)?.length, 2);
|
||||
|
||||
const near = [...lines];
|
||||
near[1] = "X";
|
||||
near[6] = "Y";
|
||||
const nearDiff = unifiedDiff(lines.join("\n") + "\n", near.join("\n") + "\n");
|
||||
assert.equal(nearDiff.match(/^@@/gm)?.length, 1);
|
||||
});
|
||||
|
||||
test("writing into an empty file adds every line", () => {
|
||||
assert.equal(
|
||||
unifiedDiff("", "hello\nworld\n"),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -0,0 +1,2 @@\n+hello\n+world\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("emptying a file removes every line", () => {
|
||||
assert.equal(
|
||||
unifiedDiff("hello\n", ""),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1 +0,0 @@\n-hello\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("a missing final newline is marked as git marks it", () => {
|
||||
assert.equal(
|
||||
unifiedDiff("a\nb", "a\nb\n"),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,2 @@\n a\n" +
|
||||
"-b\n\\ No newline at end of file\n+b\n",
|
||||
);
|
||||
assert.equal(
|
||||
unifiedDiff("a\nb\n", "a\nc"),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,2 @@\n a\n" +
|
||||
"-b\n+c\n\\ No newline at end of file\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("a pure insertion keeps the lines around it as context", () => {
|
||||
assert.equal(
|
||||
unifiedDiff("a\nc\n", "a\nb\nc\n"),
|
||||
"--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,3 @@\n a\n+b\n c\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("the file name is the one asked for", () => {
|
||||
assert.ok(
|
||||
unifiedDiff("a\n", "b\n", "CLAUDE.md").startsWith(
|
||||
"--- a/CLAUDE.md\n+++ b/CLAUDE.md\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue