Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions test/cli_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,54 @@ describe("load_layered_config profiles", () => {
expect(() => load_layered_config(work, "ghost")).toThrow(/^profile not found: ghost$/);
expect(() => load_layered_config(work, "../x")).toThrow("invalid profile name");
});

it("loads a soul-only profile as the system prompt and lists only the markdown file", () => {
const work = make_temp_dir("work");
const soul = path.join(profiles(), "bard.md");
write_file(soul, " You are a bard.\n");
const layered = load_layered_config(work, "bard");
expect(layered?.profile).toBe("bard");
expect(layered?.config).toEqual({ system_prompt: "You are a bard." });
expect(layered?.sources).toEqual([soul]);
});

it("drops a profile's work_dir and session_dir, and resolves only string plugin paths", () => {
const work = make_temp_dir("work");
write_file(global_config_path(), JSON.stringify({ theme: "lich" }));
write_file(
path.join(profiles(), "coder.json"),
JSON.stringify({
agent_name: "coder",
work_dir: "/tmp/elsewhere",
session_dir: "/tmp/sessions",
plugins: [{ path: "plug.mjs", settings: { x: 1 } }, 12, { path: 3 }, null],
}),
);
const layered = load_layered_config(work, "coder");
expect(layered?.config).toEqual({
theme: "lich",
agent_name: "coder",
plugins: [{ path: path.join(profiles(), "plug.mjs"), settings: { x: 1 } }, 12, { path: 3 }, null],
});
});

it("skips empty and non-string profile selectors, and refuses a blank-padded name", () => {
const work = make_temp_dir("work");
write_file(path.join(profiles(), "kept.json"), JSON.stringify({ agent_name: "kept" }));
write_file(global_config_path(), JSON.stringify({ profile: "kept" }));
write_file(project_config_path(work), JSON.stringify({ profile: 1 }));
process.env.LICH_PROFILE = "";
expect(load_layered_config(work)?.config).toEqual({ agent_name: "kept" });
process.env.LICH_PROFILE = " ";
expect(() => load_layered_config(work)).toThrow('invalid profile name " "');
});

it("refuses a profile JSON array instead of falling through to its soul file", () => {
const work = make_temp_dir("work");
write_file(path.join(profiles(), "coder.json"), "[]\n");
write_file(path.join(profiles(), "coder.md"), "You are a coder.");
expect(() => load_layered_config(work, "coder")).toThrow(/^invalid config json:/);
});
});

describe("merge_config_layers", () => {
Expand Down
60 changes: 60 additions & 0 deletions test/first_run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,66 @@ describe("lich init and bare lich", () => {
}
});

it("rejects a bad profile command before it writes, and a non-TTY or cancelled create writes nothing", async () => {
const profile_file = path.join(String(process.env.HOME), ".lich", "profiles", "coder.json");
await expect(run_cli(["profile"])).rejects.toThrow("profile requires list, show, create, or use");
await expect(run_cli(["profile", "list", "extra"])).rejects.toThrow("profile list takes no name");
await expect(run_cli(["profile", "show", "coder", "extra"])).rejects.toThrow("profile takes one name");
await expect(run_cli(["profile", "show"])).rejects.toThrow("profile show requires a name");
await expect(run_cli(["profile", "use", "../x"])).rejects.toThrow("invalid profile name");
expect(existsSync(profile_file)).toBe(false);

const restore_tty = set_tty(false);
try {
await expect(run_cli(["profile", "create", "coder"])).rejects.toThrow("profile create needs a TTY");
expect(existsSync(profile_file)).toBe(false);
} finally {
restore_tty();
}

const restore_on = set_tty(true);
wizard.cancel = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(await run_cli(["profile", "create", "coder"])).toBe(1);
expect(stderr.mock.calls.map((call) => String(call[0])).join("")).toContain("profile create cancelled");
expect(existsSync(profile_file)).toBe(false);
} finally {
stderr.mockRestore();
restore_on();
wizard.cancel = false;
}
});

it("shows json and soul, lists only slug names, and can select a soul-only profile", async () => {
const home = String(process.env.HOME);
const dir = path.join(home, ".lich", "profiles");
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "NotASlug.json"), "{}\n");
writeFileSync(path.join(dir, "notes.txt"), "nope\n");
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
expect(await run_cli(["profile", "list"])).toBe(0);
expect(stdout.mock.calls.map((call) => String(call[0])).join("")).toContain("no profiles in ");
writeFileSync(path.join(dir, "coder.json"), "{\n \"agent_name\": \"coder\"\n}\n");
writeFileSync(path.join(dir, "coder.md"), " soul text \n");
writeFileSync(path.join(dir, "bard.md"), "You are a bard.\n");
stdout.mockClear();
expect(await run_cli(["profile", "show", "coder"])).toBe(0);
const shown = stdout.mock.calls.map((call) => String(call[0])).join("");
expect(shown).toContain('"agent_name": "coder"');
expect(shown).toContain("(system prompt, 9 chars)");
stdout.mockClear();
expect(await run_cli(["profile", "list"])).toBe(0);
expect(stdout.mock.calls.map((call) => String(call[0])).join("")).toBe(" bard (soul)\n coder (soul)\n");
await expect(run_cli(["profile", "show", "missing"])).rejects.toThrow(/^profile not found: missing$/);
expect(await run_cli(["profile", "use", "bard"])).toBe(0);
expect(JSON.parse(readFileSync(global_config_path(), "utf8"))).toEqual({ profile: "bard" });
} finally {
stdout.mockRestore();
}
});

it("bare lich --profile skips the wizard and runs with the profile; --profile with --config is rejected", async () => {
const home = String(process.env.HOME);
mkdirSync(path.join(home, ".lich", "profiles"), { recursive: true });
Expand Down
46 changes: 44 additions & 2 deletions test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,27 @@ describe("gateway bus", () => {
try {
const records: RunRecord[] = [];
const bus = new GatewayBus({
config: config_for(work_dir, { allowed_users: { telegram: ["u1"] } }),
config: config_for(work_dir, { allowed_users: { telegram: ["u1"], discord: ["u1"] } }),
agent_factory: () => recording_agent(records),
});
await bus.handle("telegram", "t1", "u1", "/start");
await bus.handle("telegram", "t2", "u1", "/start@lich_bot deep-link");
await bus.handle("telegram", "t3", "u1", "/started a thing");
await bus.handle("webhook", "w1", "u1", "/start");
expect(records.map((run) => run.input)).toEqual(["hello", "hello", "/started a thing", "/start"]);
await bus.handle("telegram", "t4", "u1", "/start@lich_bot");
await bus.handle("telegram", "t5", "u1", "/start/foo");
await bus.handle("telegram", "t6", "u1", "/start@");
await bus.handle("discord", "d1", "u1", "/start");
expect(records.map((run) => run.input)).toEqual([
"hello",
"hello",
"/started a thing",
"/start",
"hello",
"/start/foo",
"/start@",
"/start",
]);
} finally {
rmSync(work_dir, { recursive: true, force: true });
}
Expand All @@ -484,6 +497,35 @@ describe("gateway bus", () => {
const failed = await bus.reply("webhook", "c1", "u1", "again");
expect(failed?.failed).toBe(true);
expect(failed?.text.startsWith("agent error: provider down")).toBe(true);
expect(failed?.usage).toBeUndefined();
} finally {
rmSync(work_dir, { recursive: true, force: true });
}
});

it("handle() omits an empty final reply; reply() still returns the empty text", async () => {
const work_dir = temp_work_dir();
try {
const bus = new GatewayBus({
config: config_for(work_dir),
agent_factory: () =>
({
run: async (): Promise<AgentRunResult> => ({
outcome: {
messages: [],
final: { role: "assistant", content: "" },
result: undefined,
turns_used: 1,
stopped_reason: "final",
},
messages: [],
usage_total: usage_small,
session_path: undefined,
}),
}) as unknown as Agent,
});
expect(await bus.handle("webhook", "c1", "u1", "hi")).toBeUndefined();
expect(await bus.reply("webhook", "c1", "u1", "hi")).toEqual({ text: "", usage: usage_small });
} finally {
rmSync(work_dir, { recursive: true, force: true });
}
Expand Down
Loading