diff --git a/test/cli_config.test.ts b/test/cli_config.test.ts index 6b7844e..a00220f 100644 --- a/test/cli_config.test.ts +++ b/test/cli_config.test.ts @@ -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", () => { diff --git a/test/first_run.test.ts b/test/first_run.test.ts index e81a1df..3fee2fe 100644 --- a/test/first_run.test.ts +++ b/test/first_run.test.ts @@ -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 }); diff --git a/test/gateway.test.ts b/test/gateway.test.ts index c56c261..94e1863 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -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 }); } @@ -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 => ({ + 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 }); }