RSI-Harness / test /cli.test.ts
Zihan Tan
Sync full source tree with github.com/CosmosMind-ai/RSI-Harness@33c4f8d
a861001
Raw History Blame Contribute Delete
21.1 kB
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { visibleWidth } from "@earendil-works/pi-tui";
import {
createDefaultHarnessGenome,
createHarnessGenome,
} from "../src/index.ts";
import { rsiHeaderLines, RsiEditor } from "../src/tui/rsih-tui.ts";
import {
genomeAppearanceArgs,
genomeActiveToolNames,
genomeFooterStatus,
parseCliArgs,
} from "../src/pi-cli-runtime.ts";
import {
genomeResourceIsolationArgs,
projectGenomeResources,
} from "../src/harness/pi-projection.ts";
import {
expandGenomeShorthand,
genomeShorthandReference,
} from "../src/cli/genome-shorthand.ts";
const codeDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
function runCli(directory, args) {
return spawnSync(
process.execPath,
[join(codeDirectory, "src", "cli.ts"), ...args],
{
cwd: codeDirectory,
encoding: "utf8",
env: {
...process.env,
PI_OFFLINE: "1",
PI_SKIP_VERSION_CHECK: "1",
RSIH_CODING_AGENT_DIR: join(directory, ".rsih-agent"),
},
},
);
}
/** Mirror of Pi's default session directory, which RSIH no longer overrides. */
function piSessionDir(directory) {
const canonical = realpathSync(directory);
const encoded = `--${canonical.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
return join(directory, ".rsih-agent", "sessions", encoded);
}
function sessionEntries(sessionDirectory) {
const files = readdirSync(sessionDirectory).filter((name) =>
name.endsWith(".jsonl"),
);
assert.equal(files.length, 1);
return readFileSync(join(sessionDirectory, files[0]), "utf8")
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
}
test("CLI translates RSIH compatibility flags and preserves Pi flags", () => {
assert.deepEqual(
parseCliArgs([
"-p",
"Say ok.",
"--run-id",
"cli-args",
"--profile",
"test-provider",
"--model",
"test-model",
"--json",
]),
{
cwd: ".",
maxTurns: undefined,
json: true,
newSession: false,
profile: "test-provider",
piArgs: [
"-p",
"Say ok.",
"--session-id",
"cli-args",
"--provider",
"test-provider",
"--model",
"test-model",
"--mode",
"json",
],
},
);
});
test("Genome appearance translates to Pi theme flags without overriding CLI flags", () => {
const genome = createHarnessGenome({
appearance: { theme: "dark", no_themes: true },
});
assert.deepEqual(genomeAppearanceArgs(genome), [
"--use-theme",
"dark",
"--no-themes",
]);
assert.deepEqual(
genomeAppearanceArgs(genome, ["--use-theme", "light", "--no-themes"]),
[],
);
});
test("Genome theme paths reach Pi through resource discovery", () => {
const genome = createHarnessGenome({
appearance: { themes: ["themes/coding.json"] },
skills: [{ source: "./skills/review" }],
prompt_templates: [
{ source: "./prompts" },
{ name: "plan", content: "Plan first." },
],
});
const projected = projectGenomeResources(genome, (path) =>
`/tmp/project/${path.replace(/^\.\//, "")}`,
);
assert.deepEqual(projected.themePaths, ["/tmp/project/themes/coding.json"]);
assert.deepEqual(projected.skillPaths, ["/tmp/project/skills/review"]);
// Inline templates become commands instead of discovered files.
assert.deepEqual(projected.promptPaths, ["/tmp/project/prompts"]);
});
test("resource isolation is opt-in and never hides context files", () => {
assert.deepEqual(genomeResourceIsolationArgs(createHarnessGenome()), []);
assert.deepEqual(
genomeResourceIsolationArgs(
createHarnessGenome({ resources: { isolate: true } }),
),
["--no-skills", "--no-prompt-templates", "--no-themes", "--no-extensions"],
);
assert.deepEqual(
genomeResourceIsolationArgs(
createHarnessGenome({ resources: { isolate: true } }),
["--no-skills"],
),
["--no-prompt-templates", "--no-themes", "--no-extensions"],
);
});
test("Genome tools patch Pi's active set instead of replacing it", () => {
const all = ["read", "ls", "bash", "load_skill", "mcp_search"];
// A Genome with no tools opinion leaves Pi's selection untouched.
assert.equal(
genomeActiveToolNames(all, all, createHarnessGenome()),
undefined,
);
// Disabling one tool keeps every other tool Pi had enabled.
assert.deepEqual(
genomeActiveToolNames(
all,
all,
createHarnessGenome({ tools: [{ name: "bash", enabled: false }] }),
),
["read", "ls", "load_skill", "mcp_search"],
);
// Enabling a tool Pi left off adds it; `list` still maps to Pi's `ls`.
assert.deepEqual(
genomeActiveToolNames(
["read"],
all,
createHarnessGenome({
tools: [{ name: "list", enabled: true }, { name: "bash", enabled: true }],
}),
),
["read", "ls", "bash"],
);
});
test("Genome footer status is labeled and purple", () => {
assert.equal(genomeFooterStatus("coding"), "\u001b[35mGenome: coding\u001b[39m");
});
test("RSIH TUI header includes DNA logo and runtime identity", () => {
const lines = rsiHeaderLines(
60,
{
cwd: join(homedir(), "project"),
model: "gpt-5.6-sol",
genome: "coding",
version: "0.1.0",
},
(text) => `\u001b[35m${text}\u001b[39m`,
(text) => `\u001b[1m${text}\u001b[0m`,
);
const plain = lines.join("\n").replace(/\u001b\[[0-9;]*m/g, "");
assert.match(plain, /╭─+╮/);
assert.match(plain, /[▀▄█]/);
assert.match(plain, /▄▄▄▄/);
assert.match(plain, /Workspace: ~\/project/);
assert.match(plain, /Model: gpt-5\.6-sol/);
assert.match(plain, /Genome: coding/);
assert.match(plain, /Version: 0\.1\.0/);
assert.ok(lines[0].startsWith("\u001b[35m"));
});
test("RSIH TUI header lists the loaded resources and nothing transient", () => {
const lines = rsiHeaderLines(
60,
{
cwd: join(homedir(), "project"),
model: "gpt-5.6-sol",
genome: "coding",
version: "0.1.0",
resources: {
skills: ["code-review"],
extensions: ["git.ts"],
},
},
(text) => text,
(text) => text,
);
const plain = lines.join("\n");
assert.match(plain, /\[Skills\]/);
assert.match(plain, /code-review/);
assert.match(plain, /\[Extensions\]/);
assert.match(plain, /git\.ts/);
assert.match(plain, /│/);
assert.match(plain, /A Genome-driven runtime f/);
assert.match(plain, /│\s+─{3,}\s+│/);
assert.doesNotMatch(plain, /none loaded/);
assert.doesNotMatch(plain, /Skills:/);
assert.doesNotMatch(plain, /Extensions:/);
});
test("RSIH TUI keeps a fixed column divider and compacts long workspaces", () => {
const lines = rsiHeaderLines(
120,
{
cwd: join(homedir(), "Documents/cosmos/dopapers/rsih"),
model: "gpt-5.6-sol",
genome: "default",
version: "0.1.0",
resources: { skills: [], extensions: [] },
},
(text) => text,
(text) => text,
);
const plain = lines.join("\n");
const dividerPositions = lines
.slice(1, -1)
.map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").indexOf("│", 1));
assert.equal(new Set(dividerPositions).size, 1);
assert.match(plain, /Workspace: ~\/\.\.\.\/dopapers\/rsih/);
});
test("RSIH editor preserves Pi cursor marker when padding is zero", () => {
const tui = { terminal: { rows: 42 }, requestRender() {} };
const theme = {
borderColor: (text) => `\u001b[35m${text}\u001b[39m`,
selectList: {},
};
const keybindings = { matches: () => false };
const editor = new RsiEditor(tui, theme, keybindings);
editor.focused = true;
editor.setPaddingX(0);
const inputLine = editor.render(60)[1]!;
assert.match(inputLine, /^\u001b\[35m>\u001b\[39m\u001b_pi:c\u0007/);
assert.doesNotMatch(inputLine, /^.*>\u001b\[39m_pi:c/);
assert.ok(visibleWidth(inputLine) <= 60);
});
/**
* RSIH replaces one padding cell with its prompt character and nothing else.
* It used to also strip Pi's autocomplete rows off the end of the editor so the
* header could redraw them, which put the slash-command list up in the startup
* banner instead of under the input box.
*/
test("RSIH editor keeps every row Pi's editor renders", () => {
const tui = { terminal: { rows: 42 }, requestRender() {} };
const theme = {
borderColor: (text) => `\u001b[35m${text}\u001b[39m`,
selectList: {},
};
const keybindings = { matches: () => false };
const editor = new RsiEditor(tui, theme, keybindings);
editor.focused = true;
const upstream = Object.getPrototypeOf(RsiEditor.prototype).render.call(
editor,
60,
); assert.equal(editor.render(60).length, upstream.length);
assert.equal(typeof editor.getAutocompleteLines, "undefined");
});
test("checked-in default Genome leaves Pi defaults unchanged", () => {
const checkedIn = JSON.parse(
readFileSync(
join(codeDirectory, "config", "harness.default.json"),
"utf8",
),
);
const genome = createDefaultHarnessGenome();
assert.deepEqual(checkedIn, genome);
// The default Genome must not carry a single behavioral field: anything it
// declared would be silently imposed on a bare `rsih` run.
assert.deepEqual(Object.keys(genome).sort(), [
"genome_id",
"genome_schema_version",
"parent_id",
"version",
]);
});
test("CLI runs through Pi print mode and stores Genome in Pi JSONL", () => {
const directory = mkdtempSync(join(tmpdir(), "rsih-cli-"));
const providersPath = join(directory, "providers.json");
const genomePath = join(directory, "genome.json");
writeFileSync(
providersPath,
JSON.stringify({
mock: {
kind: "mock",
model: "mock-cli",
mock_responses: ["cli ok"],
},
}),
"utf8",
);
writeFileSync(
genomePath,
JSON.stringify(
createHarnessGenome({
genome_id: "harness:cli",
model: { profile: "mock", id: "mock-cli" },
}),
),
"utf8",
);
const result = runCli(directory, [
"-p",
"Say ok.",
"--run-id",
"cli-test",
"--config",
providersPath,
"--genome",
genomePath,
"--cwd",
directory,
]);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "cli ok");
const entries = sessionEntries(piSessionDir(directory));
assert.equal(entries[0].id, "cli-test");
assert.ok(
entries.some(
(entry) =>
entry.type === "custom" &&
entry.customType === "rsih.genome" &&
entry.data.genome_id === "harness:cli",
),
);
assert.ok(
entries.some(
(entry) =>
entry.type === "message" &&
entry.message.role === "assistant" &&
entry.message.content.some(
(content) => content.type === "text" && content.text === "cli ok",
),
),
);
});
/**
* Driving a Genome from the command line: one `--run-id` names a conversation,
* and every later invocation with that id appends to it. The Genome is named
* once and restored from the session afterwards, so a script does not have to
* repeat it -- and cannot accidentally change it half way through.
*/
test("a run id keeps one conversation alive across separate invocations", () => {
const directory = mkdtempSync(join(tmpdir(), "rsih-cli-runid-"));
const providersPath = join(directory, "providers.json");
const genomesDirectory = join(directory, ".rsih", "genomes");
mkdirSync(genomesDirectory, { recursive: true });
writeFileSync(
providersPath,
JSON.stringify({
notes: { kind: "mock", model: "notes-model", mock_responses: ["noted"] },
}),
"utf8",
);
writeFileSync(
join(genomesDirectory, "notes.json"),
JSON.stringify(
createHarnessGenome({
genome_id: "harness:notes",
// Only reachable through the Genome, so a reply proves it was applied.
model: { profile: "notes", id: "notes-model" },
}),
),
"utf8",
);
const first = runCli(directory, [
"-p",
"First.",
"--config",
providersPath,
"--genome",
"notes",
"--run-id",
"conversation-1",
"--cwd",
directory,
]);
assert.equal(first.status, 0, first.stderr);
assert.equal(first.stdout.trim(), "noted");
// No --genome this time: the session carries it.
const second = runCli(directory, [
"-p",
"Second.",
"--config",
providersPath,
"--run-id",
"conversation-1",
"--cwd",
directory,
]);
assert.equal(second.status, 0, second.stderr);
assert.equal(second.stdout.trim(), "noted");
const entries = sessionEntries(piSessionDir(directory));
assert.equal(entries[0].id, "conversation-1", "--run-id names the session");
assert.deepEqual(
entries
.filter((entry) => entry.type === "message" && entry.message.role === "user")
.map((entry) => entry.message.content[0].text),
["First.", "Second."],
"the second invocation appends instead of starting over",
);
assert.equal(
entries.filter(
(entry) => entry.type === "custom" && entry.customType === "rsih.genome",
).length,
1,
"the Genome is recorded once, not re-stamped every turn",
);
});
test("CLI discovers a named Genome and resumes its Pi session", () => {
const directory = mkdtempSync(join(tmpdir(), "rsih-cli-genome-"));
const providersPath = join(directory, "providers.json");
const genomesDirectory = join(directory, ".rsih", "genomes");
mkdirSync(genomesDirectory, { recursive: true });
writeFileSync(
providersPath,
JSON.stringify({
coding: {
kind: "mock",
model: "genome-model",
mock_responses: ["named genome ok"],
},
}),
"utf8",
);
writeFileSync(
join(genomesDirectory, "coding.json"),
JSON.stringify(
createHarnessGenome({
genome_id: "harness:coding",
model: { profile: "coding", id: "genome-model" },
skills: [
{
name: "coding",
description: "Coding workflow",
content: "Use a focused coding workflow.",
},
],
}),
),
"utf8",
);
const first = runCli(directory, [
"-p",
"First.",
"--config",
providersPath,
"--genome",
"coding",
"--cwd",
directory,
]);
assert.equal(first.status, 0, first.stderr);
assert.equal(first.stdout.trim(), "named genome ok");
const firstEntries = sessionEntries(piSessionDir(directory));
const sessionId = firstEntries[0].id;
const second = runCli(directory, [
"-p",
"Second.",
"--continue",
"--config",
providersPath,
"--cwd",
directory,
]);
assert.equal(second.status, 0, second.stderr);
assert.equal(second.stdout.trim(), "named genome ok");
const entries = sessionEntries(piSessionDir(directory));
assert.equal(entries[0].id, sessionId);
assert.equal(
entries.filter(
(entry) =>
entry.type === "message" && entry.message.role === "user",
).length,
2,
);
assert.equal(
entries.filter(
(entry) =>
entry.type === "custom" && entry.customType === "rsih.genome",
).length,
1,
);
});
test("the binary ships no changelog, not Pi's", () => {
// Pi resolves CHANGELOG.md next to the package.json it reads, so pairing
// Pi's release notes with RSIH's version makes every Pi release look unseen
// and replays the "What's New" wall on every start. Shipping none is the
// intended state: Pi treats a missing changelog as no entries.
const buildScript = readFileSync(
join(codeDirectory, "scripts", "build-binary.ts"),
"utf8",
);
assert.doesNotMatch(
buildScript,
/copyFileSync\([^)]*CHANGELOG|cpSync\([^)]*CHANGELOG/,
"no changelog may ship with the binary",
);
});
test("a leading :, :: or + is shorthand for --genome", () => {
for (const argument of ["+coding", ":coding", "::coding"]) {
assert.deepEqual(expandGenomeShorthand([argument]), ["--genome", "coding"]);
}
// The rest of argv is untouched, so the shorthand composes with Pi's flags.
assert.deepEqual(expandGenomeShorthand(["+coding", "-p", "go"]), [
"--genome",
"coding",
"-p",
"go",
]);
// Path references work too; resolution is the loader's job, not the parser's.
assert.deepEqual(expandGenomeShorthand([":./my-genome"]), [
"--genome",
"./my-genome",
]);
// Expanding twice must not add a second --genome.
assert.deepEqual(
expandGenomeShorthand(expandGenomeShorthand(["+coding"])),
["--genome", "coding"],
);
});
test("the Genome shorthand does not swallow messages or option values", () => {
// Pi takes free-form messages as positional arguments and most of its options
// take values, so only the launch position is unambiguous.
assert.deepEqual(expandGenomeShorthand(["--thinking", "+x"]), [
"--thinking",
"+x",
]);
assert.deepEqual(expandGenomeShorthand(["add", "+1", "to", "the", "counter"]), [
"add",
"+1",
"to",
"the",
"counter",
]);
// A bare sigil names no Genome, and whitespace means the argument is prose.
assert.equal(genomeShorthandReference("+"), undefined);
assert.equal(genomeShorthandReference(":"), undefined);
assert.equal(genomeShorthandReference("::"), undefined);
assert.equal(genomeShorthandReference(": coding"), undefined);
assert.equal(genomeShorthandReference("coding"), undefined);
assert.equal(genomeShorthandReference(undefined), undefined);
assert.deepEqual(expandGenomeShorthand([]), []);
});
test("RSIH options accept both --option value and --option=value", () => {
// The equals spelling used to fall through to Pi untouched, which silently
// started the default Genome instead of the requested one.
assert.equal(parseCliArgs(["--genome=coding"]).genome, "coding");
assert.equal(parseCliArgs(["--genome", "coding"]).genome, "coding");
assert.equal(parseCliArgs(["--max-turns=3"]).maxTurns, 3);
assert.equal(parseCliArgs(["--cwd=/tmp"]).cwd, "/tmp");
assert.deepEqual(parseCliArgs(["--profile=local"]).piArgs, [
"--provider",
"local",
]);
assert.equal(parseCliArgs(["--genome=coding"]).piArgs.length, 0);
assert.throws(() => parseCliArgs(["--genome="]), /--genome requires a value/);
});
test("launching with the +name shorthand resolves that Genome", () => {
// The unit tests cover the rewrite; this covers the wiring in src/cli.ts,
// which is the part that can silently not be called.
const directory = mkdtempSync(join(tmpdir(), "rsih-cli-shorthand-"));
const providersPath = join(directory, "providers.json");
const genomesDirectory = join(directory, ".rsih", "genomes");
mkdirSync(genomesDirectory, { recursive: true });
writeFileSync(
providersPath,
JSON.stringify({
coding: {
kind: "mock",
model: "genome-model",
mock_responses: ["shorthand ok"],
},
}),
"utf8",
);
writeFileSync(
join(genomesDirectory, "coding.json"),
JSON.stringify(
createHarnessGenome({
genome_id: "harness:coding-shorthand",
model: { profile: "coding", id: "genome-model" },
}),
),
"utf8",
);
const result = runCli(directory, [
"+coding",
"-p",
"First.",
"--config",
providersPath,
"--cwd",
directory,
]);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "shorthand ok");
assert.ok(
sessionEntries(piSessionDir(directory)).some(
(entry) =>
entry.type === "custom" &&
entry.customType === "rsih.genome" &&
entry.data.genome_id === "harness:coding-shorthand",
),
);
});
test("installing carries the whole payload, not just the executable", () => {
// The binary resolves its version, built-in Genome seeds and themes relative
// to its own directory. Copying only the executable onto the PATH produced an
// rsih that reported 0.0.0 and could not find `paperlab` at all, so the install
// step has to move the payload and link to it.
const buildScript = readFileSync(
join(codeDirectory, "scripts", "build-binary.ts"),
"utf8",
);
assert.match(buildScript, /cpSync\(dirname\(outputPath\), libDirectory/);
assert.match(buildScript, /symlinkSync\(target, link\)/);
assert.doesNotMatch(
buildScript,
/copyFileSync\(outputPath, installPath\)/,
"install must not copy the bare executable onto the PATH",
);
// install.sh must not reimplement any of that.
const installScript = readFileSync(join(codeDirectory, "install.sh"), "utf8");
assert.match(installScript, /npm run --silent install:binary/);
// And it has to prove the payload arrived rather than trust the copy.
assert.match(installScript, /rsih" genome validate paperlab/);
});