forked from enderofwings/NexusOS
feat: self-alteration tools (edit_playbook, edit_settings, edit_source)
Gives the assistant three new ACTION tools to change its own playbooks,
runtime settings, and (source checkout only) its own source code, all
reusing the existing run_snippet/remember approval framework — but with
a hardcoded floor (self_edit.ALWAYS_ASK_TOOLS) so these three always pause
for per-call human approval regardless of the global action_tool_policy
setting. Flipping that policy for an unrelated tool must never silently
also unlock unattended self-modification.
synapse/self_edit.py is the new module doing the actual work, documented
in the same explicit "here's what is and isn't a security boundary" style
as code_run.py:
- edit_source is confined to settings.project_root via the same
realpath + Path.parents boundary check that just closed a sibling-
directory bypass in /icons/image, plus a denylist of dangerous
subtrees (.git, the venv, node_modules, build output, runtime state).
Gated on settings.source_checkout — refuses cleanly in a wheel
install, where there's no live repo to edit or commit into.
- The model sends full file content, never a diff; the server computes
the diff itself via difflib against what's actually on disk, so a
human reviews ground truth, not a description the model wrote.
- Every applied source edit best-effort commits to git as an audit
trail — independent of, not a substitute for, the approval gate.
- edit_playbook merges instead of replacing (main.py's prior
_persist_playbook did a raw replace, which was only safe because the
frontend form always sent a complete object — unsafe for a tool a
model calls with a partial argument set, so this also fixes that
latent bug). Becoming the active system prompt requires an explicit
make_active flag, never a side effect of an ordinary edit.
- edit_settings reuses the existing _SETTINGS_DEFAULTS allowlist.
The approval UI (Chatbot.jsx) previously rendered a tool call's arguments
as Object.values(args).join(", ") in a single-line badge — unusable for
reviewing a diff. It now renders a real, server-computed preview (diff
for source, before/after for playbook/settings) via a new shared
diff-view.js helper, with a loud banner when a change would become the
active system prompt or touch action_tool_policy/system_prompt. A new
nexus-edit fence (self-edit-langs.js + Markdown.jsx's EditBlock) shows
the same diff after an edit is applied, mirroring nexus-run.
Verified: 212 backend tests pass (29 new in test_self_edit.py; the 12
pre-existing C/C++/Rust toolchain failures are unrelated and unchanged),
57 frontend node:test cases pass (15 new), eslint and vite build clean,
and the full approval-preview render path was exercised against the real
built UI with a mocked SSE stream covering all four preview branches
(source diff, playbook becomes-main, settings policy-change, and a
rejected/failing preview).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
|
||||
import { API_BASE } from "./config";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
|
||||
|
||||
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
@@ -728,13 +729,16 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
||||
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
|
||||
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
|
||||
⚠️ The assistant wants to run:
|
||||
{" "}
|
||||
{pendingApproval.map((a, i) => (
|
||||
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
|
||||
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
{pendingApproval.map((a, i) =>
|
||||
a.preview
|
||||
? <ActionPreview key={i} action={a} />
|
||||
: (
|
||||
<code key={i} style={{ display: "inline-block", color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem", marginBottom: "0.4rem" }}>
|
||||
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
|
||||
</code>
|
||||
)
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button onClick={() => resolveApproval(true)}
|
||||
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
|
||||
@@ -817,4 +821,102 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One self-edit tool's approval preview: a real, server-computed diff instead
|
||||
// of the flat "name(arg, arg)" one-liner used for every other action tool.
|
||||
// This is what makes "always ask first" mean actual informed consent for
|
||||
// edit_source/edit_playbook/edit_settings — the human reviews what will
|
||||
// actually change, not a description of it. See synapse/self_edit.py's
|
||||
// preview_* functions, which compute exactly what's rendered here.
|
||||
function ActionPreview({ action }) {
|
||||
const { name, preview } = action;
|
||||
const banner = (text) => (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.6rem", marginBottom: "0.4rem", background: "#3a2a10",
|
||||
border: "1px solid #8a6a2a", borderRadius: "6px", color: "#ffd580",
|
||||
fontSize: "0.8rem", fontWeight: 600,
|
||||
}}>
|
||||
⚠ {text}
|
||||
</div>
|
||||
);
|
||||
const diffBox = (lines) => (
|
||||
<pre style={{
|
||||
background: "#0d0d0d", border: "1px solid #333", borderRadius: "6px",
|
||||
padding: "0.5rem 0.7rem", margin: "0 0 0.4rem", fontSize: "0.8rem",
|
||||
lineHeight: "1.4", maxHeight: "16rem", overflow: "auto",
|
||||
}}>
|
||||
{lines.length === 0 && <span style={{ color: "#666" }}>(no changes)</span>}
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
|
||||
if (!preview.ok) {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>{name}</code>
|
||||
{" — this will fail:"}
|
||||
</div>
|
||||
<div style={{ color: "#ff8a80", fontSize: "0.8rem", marginBottom: "0.4rem" }}>{preview.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_source") {
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_source</code>
|
||||
{" "}{preview.path}{preview.is_new_file ? " (new file)" : ""}
|
||||
</div>
|
||||
{diffBox(diffLines(preview.diff))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_playbook") {
|
||||
const keys = ["title", "goal", "instructions", "tags", "tools", "model"];
|
||||
const lines = keyValueDiffLines(preview.before, preview.after, keys);
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_playbook</code>
|
||||
{" "}{preview.is_new ? "(new playbook)" : preview.after?.title}
|
||||
</div>
|
||||
{preview.becomes_main_playbook && banner("this will become the active system prompt")}
|
||||
{diffBox(lines)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "edit_settings") {
|
||||
const before = {}, after = {};
|
||||
for (const [k, v] of Object.entries(preview.applied || {})) {
|
||||
before[k] = v.before;
|
||||
after[k] = v.after;
|
||||
}
|
||||
const lines = keyValueDiffLines(before, after, Object.keys(preview.applied || {}));
|
||||
return (
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
|
||||
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_settings</code>
|
||||
</div>
|
||||
{preview.policy_change && banner("this changes the tool-approval policy itself")}
|
||||
{preview.system_prompt_change && banner("this changes the fallback system prompt")}
|
||||
{diffBox(lines)}
|
||||
{preview.ignored_unknown && preview.ignored_unknown.length > 0 && (
|
||||
<div style={{ fontSize: "0.75rem", color: "#888" }}>
|
||||
ignored (not a real setting): {preview.ignored_unknown.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
|
||||
// The execution track's display half: a ```nexus-run fence is a run result
|
||||
// (source + captured output), not a program to execute here. See run-langs.js.
|
||||
import { RUN_FENCE_LANG, parseRunResult } from "./preview/run-langs.js";
|
||||
// Self-modification's display half: a ```nexus-edit fence is the result of an
|
||||
// already-applied, already-approved edit_source/edit_playbook/edit_settings
|
||||
// call. See self-edit-langs.js.
|
||||
import { EDIT_FENCE_LANG, parseEditResult } from "./preview/self-edit-langs.js";
|
||||
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
|
||||
|
||||
// Parse content into an array of {type, value, lang, streaming} blocks.
|
||||
// Handles:
|
||||
@@ -72,6 +77,10 @@ export function Markdown({ content }) {
|
||||
const run = block.streaming ? null : parseRunResult(block.value);
|
||||
if (run) return <RunBlock key={i} run={run} />;
|
||||
}
|
||||
if (lang === EDIT_FENCE_LANG) {
|
||||
const edit = block.streaming ? null : parseEditResult(block.value);
|
||||
if (edit) return <EditBlock key={i} edit={edit} />;
|
||||
}
|
||||
return RENDERABLE_LANGS.has(lang)
|
||||
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
||||
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
|
||||
@@ -252,6 +261,114 @@ function RunBlock({ run }) {
|
||||
);
|
||||
}
|
||||
|
||||
// A finished self-edit: the diff/change that was actually applied, plus (for
|
||||
// edit_source) the git commit it landed as. Like RunBlock, nothing executes
|
||||
// here — the edit already happened on the backend under approval, and by the
|
||||
// time this renders it's history. Styled the same way as CodeBlock/RunBlock
|
||||
// for visual consistency, with diff lines colored via the shared
|
||||
// diff-view.js vocabulary rather than a syntax-highlighting library.
|
||||
function EditBlock({ edit }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyText =
|
||||
edit.kind === "source" ? edit.diff
|
||||
: edit.kind === "playbook" ? edit.instructions
|
||||
: JSON.stringify(edit.applied, null, 2);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText((copyText || "").trimEnd()).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const title =
|
||||
edit.kind === "source" ? `edited ${edit.path}`
|
||||
: edit.kind === "playbook" ? `playbook: ${edit.title || edit.id}`
|
||||
: "settings changed";
|
||||
|
||||
const lines =
|
||||
edit.kind === "source" ? diffLines(edit.diff)
|
||||
: edit.kind === "settings"
|
||||
? keyValueDiffLines(
|
||||
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.before])),
|
||||
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.after])),
|
||||
Object.keys(edit.applied),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: "#0d0d0d",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "6px",
|
||||
margin: "0.5rem 0",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.3rem 0.75rem",
|
||||
background: "#161616",
|
||||
borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.75rem", color: "#888", fontFamily: "monospace" }}>
|
||||
{title}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{edit.commit && (
|
||||
<span style={{ fontSize: "0.7rem", fontFamily: "monospace", color: "#7a92a8" }}>
|
||||
commit {edit.commit}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{edit.kind === "playbook" && edit.isMainPlaybook && (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
|
||||
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
⚠ this is now the active system prompt
|
||||
</div>
|
||||
)}
|
||||
{(edit.kind === "settings") && (edit.policyChange || edit.systemPromptChange) && (
|
||||
<div style={{
|
||||
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
|
||||
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
|
||||
}}>
|
||||
⚠ {edit.policyChange && "changed the tool-approval policy"}
|
||||
{edit.policyChange && edit.systemPromptChange && " and "}
|
||||
{edit.systemPromptChange && "changed the fallback system prompt"}
|
||||
</div>
|
||||
)}
|
||||
{edit.kind === "playbook" ? (
|
||||
<pre style={{
|
||||
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
|
||||
lineHeight: "1.5", margin: 0, fontFamily: "monospace", color: "#ccc",
|
||||
}}>
|
||||
<code>{(edit.instructions || "").trimEnd()}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<pre style={{
|
||||
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
|
||||
lineHeight: "1.5", margin: 0, fontFamily: "monospace",
|
||||
maxHeight: "24rem", overflowY: "auto",
|
||||
}}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Security-Policy for the rendered preview. Together with the iframe's
|
||||
// `sandbox` attribute below, this is the entire trust boundary for model-
|
||||
// authored HTML/SVG, so it stays conservative rather than convenient:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* diff-view.js — shared plain-text diff-line classification, no dependency.
|
||||
*
|
||||
* There is no diff/syntax-highlighting library in this project (see
|
||||
* package.json), so a unified-diff string is colored by hand: split into
|
||||
* lines, tag each by its leading character. Used by both the self-edit
|
||||
* approval banner (Chatbot.jsx, reviewing a change BEFORE it's applied) and
|
||||
* the nexus-edit result block (Markdown.jsx, showing one AFTER) — one small
|
||||
* shared vocabulary so both read the same way.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Split unified-diff text (from difflib.unified_diff on the backend) into
|
||||
* {text, kind} lines. kind is one of "add" | "remove" | "hunk" | "file" |
|
||||
* "context". The "+++"/"---" file markers and "@@" hunk headers get their own
|
||||
* kind so they aren't colored as if they were real added/removed lines (a
|
||||
* bare "+++" line is not "code that was added").
|
||||
*/
|
||||
export function diffLines(text) {
|
||||
return (text || "").replace(/\n$/, "").split("\n").map((line) => {
|
||||
if (line.startsWith("+++") || line.startsWith("---")) return { text: line, kind: "file" };
|
||||
if (line.startsWith("@@")) return { text: line, kind: "hunk" };
|
||||
if (line.startsWith("+")) return { text: line, kind: "add" };
|
||||
if (line.startsWith("-")) return { text: line, kind: "remove" };
|
||||
return { text: line, kind: "context" };
|
||||
});
|
||||
}
|
||||
|
||||
export const DIFF_LINE_COLOR = {
|
||||
add: "#8aff8a",
|
||||
remove: "#ff8a80",
|
||||
hunk: "#7a92a8",
|
||||
file: "#7a92a8",
|
||||
context: "#ccc",
|
||||
};
|
||||
|
||||
/**
|
||||
* before/after key-value pairs (playbook fields, settings keys) rendered
|
||||
* through the same add/remove vocabulary as a real diff, so a settings or
|
||||
* playbook change reads the same way a source diff does: one line removed,
|
||||
* one line added, per changed key. Unchanged keys are omitted entirely —
|
||||
* this is "what's different," not a full dump.
|
||||
*/
|
||||
export function keyValueDiffLines(before, after, keys) {
|
||||
const lines = [];
|
||||
for (const key of keys) {
|
||||
const b = before ? before[key] : undefined;
|
||||
const a = after ? after[key] : undefined;
|
||||
const bStr = JSON.stringify(b);
|
||||
const aStr = JSON.stringify(a);
|
||||
if (bStr === aStr) continue;
|
||||
if (b !== undefined) lines.push({ text: `- ${key}: ${bStr}`, kind: "remove" });
|
||||
if (a !== undefined) lines.push({ text: `+ ${key}: ${aStr}`, kind: "add" });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffLines, keyValueDiffLines } from "./diff-view.js";
|
||||
|
||||
test("diffLines tags add/remove/hunk/file/context lines", () => {
|
||||
const text = "--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,1 @@\n-old\n+new\n unchanged\n";
|
||||
const lines = diffLines(text);
|
||||
assert.equal(lines[0].kind, "file");
|
||||
assert.equal(lines[1].kind, "file");
|
||||
assert.equal(lines[2].kind, "hunk");
|
||||
assert.equal(lines[3].kind, "remove");
|
||||
assert.equal(lines[4].kind, "add");
|
||||
assert.equal(lines[5].kind, "context");
|
||||
});
|
||||
|
||||
test("diffLines drops exactly one trailing newline, not trailing blank lines", () => {
|
||||
const lines = diffLines("a\nb\n");
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(lines[1].text, "b");
|
||||
});
|
||||
|
||||
test("diffLines on empty text returns one empty context line, not a crash", () => {
|
||||
const lines = diffLines("");
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].text, "");
|
||||
});
|
||||
|
||||
test("keyValueDiffLines only emits changed keys", () => {
|
||||
const lines = keyValueDiffLines({ a: 1, b: 2 }, { a: 1, b: 3 }, ["a", "b"]);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0].text, /^- b: 2$/);
|
||||
assert.match(lines[1].text, /^\+ b: 3$/);
|
||||
});
|
||||
|
||||
test("keyValueDiffLines handles a null before (brand-new object)", () => {
|
||||
const lines = keyValueDiffLines(null, { title: "New" }, ["title"]);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].kind, "add");
|
||||
assert.match(lines[0].text, /title: "New"/);
|
||||
});
|
||||
|
||||
test("keyValueDiffLines emits nothing when nothing changed", () => {
|
||||
assert.deepEqual(keyValueDiffLines({ a: 1 }, { a: 1 }, ["a"]), []);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* self-edit-langs.js — display half of the self-modification tools
|
||||
* (edit_source / edit_playbook / edit_settings), the counterpart to
|
||||
* run-langs.js. Nothing executes or applies here: by the time this parses a
|
||||
* fence, the write (if any) already happened on the backend under the user's
|
||||
* per-call approval, in synapse/self_edit.py. This side only labels and lays
|
||||
* out what was returned.
|
||||
*
|
||||
* One fence tag, three payload shapes (source / playbook / settings), because
|
||||
* all three go through the same approval round-trip and the same "carry what
|
||||
* happened in one block" idea as run_snippet's nexus-run fence.
|
||||
*/
|
||||
|
||||
export const EDIT_FENCE_LANG = "nexus-edit";
|
||||
|
||||
/**
|
||||
* Parse a nexus-edit fence body. Returns null for anything malformed or of an
|
||||
* unrecognized kind — the caller falls back to showing the block as plain
|
||||
* code, the same honest-fallback behavior as parseRunResult.
|
||||
*/
|
||||
export function parseEditResult(text) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== "object") return null;
|
||||
|
||||
if (data.kind === "source") {
|
||||
if (typeof data.path !== "string" || typeof data.diff !== "string") return null;
|
||||
return {
|
||||
kind: "source",
|
||||
path: data.path,
|
||||
diff: data.diff,
|
||||
commit: typeof data.commit === "string" ? data.commit : null,
|
||||
instruction: typeof data.instruction === "string" ? data.instruction : "",
|
||||
};
|
||||
}
|
||||
|
||||
if (data.kind === "playbook") {
|
||||
if (typeof data.id !== "string") return null;
|
||||
return {
|
||||
kind: "playbook",
|
||||
id: data.id,
|
||||
title: typeof data.title === "string" ? data.title : "",
|
||||
goal: typeof data.goal === "string" ? data.goal : "",
|
||||
instructions: typeof data.instructions === "string" ? data.instructions : "",
|
||||
isMainPlaybook: !!data.is_main_playbook,
|
||||
};
|
||||
}
|
||||
|
||||
if (data.kind === "settings") {
|
||||
if (!data.applied || typeof data.applied !== "object") return null;
|
||||
return {
|
||||
kind: "settings",
|
||||
applied: data.applied,
|
||||
ignoredUnknown: Array.isArray(data.ignored_unknown) ? data.ignored_unknown : [],
|
||||
policyChange: !!data.policy_change,
|
||||
systemPromptChange: !!data.system_prompt_change,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* The self-edit result envelope: what parseEditResult will and won't accept.
|
||||
*
|
||||
* Same reasoning as run-langs.test.js: this parses text a model chose to
|
||||
* paste, so the malformed cases matter as much as the well-formed ones. A bad
|
||||
* envelope must return null so the caller falls back to plain code, not a
|
||||
* half-built object that renders a change that never happened.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EDIT_FENCE_LANG, parseEditResult } from "./self-edit-langs.js";
|
||||
|
||||
test("the fence tag is the one the backend emits", () => {
|
||||
assert.equal(EDIT_FENCE_LANG, "nexus-edit");
|
||||
});
|
||||
|
||||
test("a well-formed source envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "source", ok: true, path: "synapse/tools.py", diff: "--- a\n+++ b\n",
|
||||
commit: "abc1234", instruction: "restart needed",
|
||||
}));
|
||||
assert.equal(edit.kind, "source");
|
||||
assert.equal(edit.path, "synapse/tools.py");
|
||||
assert.equal(edit.commit, "abc1234");
|
||||
assert.equal(edit.instruction, "restart needed");
|
||||
});
|
||||
|
||||
test("a source envelope with no commit reads as null, not undefined", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "source", path: "x.py", diff: "", commit: null,
|
||||
}));
|
||||
assert.equal(edit.commit, null);
|
||||
});
|
||||
|
||||
test("a source envelope missing path or diff is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "source", diff: "x" })), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "source", path: "x.py" })), null);
|
||||
});
|
||||
|
||||
test("a well-formed playbook envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "playbook", id: "p1", title: "T", goal: "G", instructions: "I",
|
||||
is_main_playbook: true,
|
||||
}));
|
||||
assert.equal(edit.kind, "playbook");
|
||||
assert.equal(edit.id, "p1");
|
||||
assert.equal(edit.isMainPlaybook, true);
|
||||
});
|
||||
|
||||
test("a playbook envelope missing id is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "playbook", title: "T" })), null);
|
||||
});
|
||||
|
||||
test("a well-formed settings envelope parses into display fields", () => {
|
||||
const edit = parseEditResult(JSON.stringify({
|
||||
kind: "settings",
|
||||
applied: { model: { before: "a", after: "b" } },
|
||||
ignored_unknown: ["nope"],
|
||||
policy_change: true,
|
||||
system_prompt_change: false,
|
||||
}));
|
||||
assert.equal(edit.kind, "settings");
|
||||
assert.deepEqual(edit.applied, { model: { before: "a", after: "b" } });
|
||||
assert.deepEqual(edit.ignoredUnknown, ["nope"]);
|
||||
assert.equal(edit.policyChange, true);
|
||||
assert.equal(edit.systemPromptChange, false);
|
||||
});
|
||||
|
||||
test("a settings envelope missing applied is rejected", () => {
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "settings" })), null);
|
||||
});
|
||||
|
||||
test("malformed or foreign envelopes are rejected", () => {
|
||||
assert.equal(parseEditResult("not json"), null);
|
||||
assert.equal(parseEditResult("null"), null);
|
||||
assert.equal(parseEditResult("[1,2,3]"), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ kind: "unknown_kind" })), null);
|
||||
assert.equal(parseEditResult(JSON.stringify({ path: "x.py", diff: "" })), null);
|
||||
});
|
||||
Reference in New Issue
Block a user