diff --git a/README.md b/README.md index 0fd504c..e5c3944 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I binary = "pi", extensions = true, skills = false, + skill_paths = {}, thinking = "off", reload = true, auto_close = true, @@ -174,12 +175,78 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I Without lazy.nvim, pass the contents of `opts` above to `require("ai").setup()`. -Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default, while skills are disabled unless explicitly enabled. +Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default. After each completed AI turn, `reload = true` reloads every loaded file buffer whose file changed on disk during that turn. This keeps Neovim synchronized with agent edits, including replacing unsaved buffer contents when the agent changed the same file. Set `reload = false` to disable this behavior. Set `chat.show_hints = false` to hide the contextual hint bar. The `chat.hints.input` and `chat.hints.display` lists are rendered exactly in their configured order. Set `chat.keys = false` to disable all chat-specific keymaps, or set `chat.keys.input` or `chat.keys.display` to `false` to disable one group. +### Skills + +No skills are bundled or enabled by default. + +- `skills = true` enables Pi's skill discovery, including `~/.agents/skills/`. +- `skill_paths = { ... }` loads local `SKILL.md` files or directories, even with `skills = false`. + +Only a leading `~/` is expanded. Other path text is passed to Pi unchanged, and relative paths use the agent's working directory. + +Pi loads skill instructions when needed. To invoke a skill explicitly, send `/skill:name` followed by your request. + +### System prompt + +Append text, file contents, or both: + +```lua +require("ai").setup({ + append_system_prompt = "Keep responses concise.", + append_system_prompt_filepath = "~/.config/nvim/ai/writing.md", +}) +``` + +Both options are optional and accept a string or list of strings. Text comes first, then file contents, in list order with blank lines between entries. + +To append multiple prompts: + +```lua +require("ai").setup({ + append_system_prompt = { + "Keep responses concise.", + "Explain your changes.", + }, + append_system_prompt_filepath = { + "~/.config/nvim/ai/writing.md", + "~/.config/nvim/ai/project.md", + }, +}) +``` + +Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Only a leading `~/` is expanded; relative paths use the agent's working directory. + +The plugin passes a nonempty combined prompt through an owner-only temporary file, so its size does not count against the operating system's command-line limit. `append_system_prompt` text stays literal even when it matches an existing filename. + +The separate `system_prompt` option replaces Pi's default prompt. When its value names an existing path, Pi keeps its file-input behavior and ai.nvim leaves that file in place. Other nonempty values, including large literal prompts, use an owner-only temporary file. The plugin removes its temporary files when the backend exits, the session is stopped, or startup fails. Model context limits still apply. + +### Recommended upstream skill + +[Unslop](https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md) by [Lauren Tan](https://github.com/poteto) removes common AI writing patterns. It is [MIT licensed](https://github.com/cursor/plugins/blob/main/pstack/LICENSE). + +With Unslop installed in `~/.agents/skills/unslop/`: + +```lua +require("ai").setup({ + skill_paths = { + "~/.agents/skills/unslop/SKILL.md", + }, + append_system_prompt_filepath = "~/.config/nvim/ai/unslop.md", +}) +``` + +Unslop disables automatic model invocation. Use `/skill:unslop`, or create `~/.config/nvim/ai/unslop.md` with: + +```markdown +Before writing or editing prose, read `~/.agents/skills/unslop/SKILL.md` and follow its Unslop instructions. +``` + ## Tests ```sh diff --git a/lua/ai/config.lua b/lua/ai/config.lua index 756fa02..6b0dcaa 100644 --- a/lua/ai/config.lua +++ b/lua/ai/config.lua @@ -5,6 +5,7 @@ M.defaults = { binary = "pi", extensions = true, skills = false, + skill_paths = {}, thinking = "off", reload = true, auto_close = true, diff --git a/lua/ai/pi/command.lua b/lua/ai/pi/command.lua index 1c1ab07..d54bc01 100644 --- a/lua/ai/pi/command.lua +++ b/lua/ai/pi/command.lua @@ -1,15 +1,18 @@ local M = {} +local Prompt = require("ai.prompt") ---@class ai.PiCommandOpts ---@field cmd? string[] ---@field binary? string ---@field extensions? boolean ----@field skills? boolean +---@field skills? boolean Enable Pi's skill discovery. +---@field skill_paths? string[] Local skill files or directories, loaded regardless of discovery. ---@field provider? string ---@field model? string ---@field thinking? string ---@field system_prompt? string ----@field append_system_prompt? string|string[] +---@field append_system_prompt? string|string[] Text to append. +---@field append_system_prompt_filepath? string|string[] Readable local files to append after text. ---@class ai.PiOpts: ai.PiCommandOpts ---@field agent_spawn_dir string @@ -25,10 +28,18 @@ local function add_option(command, flag, value) end end +local function expand_home(path) + if path:sub(1, 2) == "~/" then + return vim.fn.expand("~") .. path:sub(2) + end + return path +end + ---Builds the command used to launch the Pi RPC process. ---@param opts ai.PiCommandOpts +---@param prepare_prompt? fun(text: string, flag: string): string ---@return string[] -function M.build(opts) +function M.build(opts, prepare_prompt) assert(type(opts) == "table", "Pi options are required") if opts.cmd then @@ -52,20 +63,39 @@ function M.build(opts) if not opts.skills then command[#command + 1] = "--no-skills" end + if opts.skill_paths ~= nil then + assert( + type(opts.skill_paths) == "table" and vim.islist(opts.skill_paths), + "skill_paths must be a list of paths" + ) + for _, path in ipairs(opts.skill_paths) do + assert( + type(path) == "string", + "skill_paths entries must be strings" + ) + assert( + not path:match("^%a[%w+.-]*://"), + "skill_paths entries must be local paths: " .. path + ) + add_option(command, "--skill", expand_home(path)) + end + end add_option(command, "--provider", opts.provider) add_option(command, "--model", opts.model) add_option(command, "--thinking", opts.thinking) - add_option(command, "--system-prompt", opts.system_prompt) - local prompts = opts.append_system_prompt - if type(prompts) == "string" then - prompts = { prompts } - end - for _, prompt in ipairs(prompts or {}) do - add_option(command, "--append-system-prompt", prompt) + local function add_prompt(flag, text) + if text ~= nil and text ~= "" then + assert(type(text) == "string", flag .. " must be a string") + if prepare_prompt then + text = prepare_prompt(text, flag) + end + add_option(command, flag, text) + end end - + add_prompt("--system-prompt", opts.system_prompt) + add_prompt("--append-system-prompt", Prompt.build(opts)) return command end diff --git a/lua/ai/pi/init.lua b/lua/ai/pi/init.lua index f0584dc..21f6f4c 100644 --- a/lua/ai/pi/init.lua +++ b/lua/ai/pi/init.lua @@ -6,16 +6,30 @@ ---@field request_id integer ---@field cancelled boolean ---@field stdin_closed boolean +---@field prompt_files string[] ---@field result? vim.SystemCompleted local Pi = {} Pi.__index = Pi local Command = require("ai.pi.command") local Events = require("ai.events") +local Prompt = require("ai.prompt") local AIAction = Events.AIAction local EventType = Events.Type +---Releases prompt files before user callbacks can interrupt teardown. +local function cleanup_prompt_files(pi) + local paths = pi.prompt_files + pi.prompt_files = {} + for _, path in ipairs(paths) do + local removed, err, code = vim.uv.fs_unlink(path) + if not removed and code ~= "ENOENT" then + pcall(vim.notify, "Could not delete temporary system prompt file: " .. tostring(err), vim.log.levels.WARN) + end + end +end + -- Decodes one JSON event line without raising parse errors. local function decode_event(line) local ok, decoded = pcall(vim.json.decode, line) @@ -287,6 +301,7 @@ function Pi:cancel() if not ok or not closing then pcall(self.process.kill, self.process, 15) end + cleanup_prompt_files(self) end ---Builds the command used to launch the Pi RPC process. @@ -314,38 +329,51 @@ function Pi.start(opts, dispatch) cancelled = false, stdin_closed = false, result = nil, + prompt_files = {}, }, Pi) - local ok, process = pcall(vim.system, Command.build(opts), { - cwd = opts.agent_spawn_dir, - text = true, - stdin = true, - -- Feeds scheduled stdout chunks into the Pi line buffer. - stdout = vim.schedule_wrap(function(err, data) - if err then - pi.dispatch({ - type = EventType.ERROR, - message = tostring(err), - source = "stdout", - }) - return - end - if not pi.cancelled then - pi:feed_stdout(data) + local ok, process = pcall(function() + local command = Command.build(opts, function(text, flag) + if flag == "--system-prompt" and Prompt.is_path(text, opts.agent_spawn_dir) then + return text end - end), - }, vim.schedule_wrap(function(result) - -- Flushes buffered output and reports process completion. - pi:flush_stdout() - pi.pending_messages = {} - pi.result = result - pi.dispatch({ - type = EventType.EXIT, - result = pi.result, - }) - end)) + local path = Prompt.write_temp(text) + pi.prompt_files[#pi.prompt_files + 1] = path + return path + end) + return vim.system(command, { + cwd = opts.agent_spawn_dir, + text = true, + stdin = true, + -- Feeds scheduled stdout chunks into the Pi line buffer. + stdout = vim.schedule_wrap(function(err, data) + if err then + pi.dispatch({ + type = EventType.ERROR, + message = tostring(err), + source = "stdout", + }) + return + end + if not pi.cancelled then + pi:feed_stdout(data) + end + end), + }, vim.schedule_wrap(function(result) + cleanup_prompt_files(pi) + -- Flushes buffered output and reports process completion. + pi:flush_stdout() + pi.pending_messages = {} + pi.result = result + pi.dispatch({ + type = EventType.EXIT, + result = pi.result, + }) + end)) + end) if not ok then + cleanup_prompt_files(pi) return nil, process end diff --git a/lua/ai/prompt.lua b/lua/ai/prompt.lua new file mode 100644 index 0000000..18f991c --- /dev/null +++ b/lua/ai/prompt.lua @@ -0,0 +1,105 @@ +local M = {} + +local function strings(value, option, allow_empty) + if value == nil then + return {} + end + if type(value) == "string" then + value = { value } + end + assert( + type(value) == "table" and vim.islist(value), + option .. " must be a string or a list of strings" + ) + for _, entry in ipairs(value) do + assert( + type(entry) == "string" and (allow_empty or entry ~= ""), + option .. " entries must be " .. (allow_empty and "strings" or "non-empty strings") + ) + end + return value +end + +local function absolute_path(path, cwd) + if not path:match("^/") and not path:match("^%a:[/\\]") then + return cwd .. "/" .. path + end + return path +end + +local function read_prompt(path, cwd) + assert( + not path:match("^%a[%w+.-]*://"), + "append_system_prompt_filepath must be a local path: " .. path + ) + if path:sub(1, 2) == "~/" then + path = vim.fn.expand("~") .. path:sub(2) + end + path = absolute_path(path, cwd) + local stat = vim.uv.fs_stat(path) + assert( + stat and stat.type == "file", + "append_system_prompt_filepath: expected a file: " .. path + ) + return table.concat(vim.fn.readfile(path, "b"), "\n") +end + +---Checks the existing-path interpretation used by Pi's system_prompt option. +---@param text string +---@param cwd string +---@return boolean +function M.is_path(text, cwd) + return vim.uv.fs_stat(absolute_path(text, cwd)) ~= nil +end + +---Writes prompt text to an exclusively created, owner-only temporary file. +---@param text string +---@return string path The caller owns deletion of this file. +function M.write_temp(text) + assert(type(text) == "string", "system prompt must be a string") + local path = vim.fn.tempname() + local fd, open_err = vim.uv.fs_open(path, "wx", 384) + assert(fd, "Could not create temporary system prompt file: " .. tostring(open_err)) + + local ok, err = pcall(function() + local offset = 0 + while offset < #text do + local written, write_err = vim.uv.fs_write(fd, text:sub(offset + 1), offset) + assert(written and written > 0, "Could not write temporary system prompt file: " .. tostring(write_err)) + offset = offset + written + end + local closed, close_err = vim.uv.fs_close(fd) + assert(closed, "Could not close temporary system prompt file: " .. tostring(close_err)) + fd = nil + end) + if not ok then + if fd then + pcall(vim.uv.fs_close, fd) + end + pcall(vim.uv.fs_unlink, path) + error(err, 0) + end + return path +end + +---Combines configured text and file contents when starting a session. +---@param opts table +---@return string +function M.build(opts) + local parts = {} + local function append(text) + if text ~= "" then + parts[#parts + 1] = text + end + end + for _, text in ipairs(strings(opts.append_system_prompt, "append_system_prompt", true)) do + append(text) + end + local paths = strings(opts.append_system_prompt_filepath, "append_system_prompt_filepath", false) + for _, path in ipairs(paths) do + append(read_prompt(path, opts.agent_spawn_dir or vim.fn.getcwd())) + end + return table.concat(parts, "\n\n") +end + +return M diff --git a/tests/config_e2e.lua b/tests/config_e2e.lua index b169ba0..5d1a09a 100644 --- a/tests/config_e2e.lua +++ b/tests/config_e2e.lua @@ -16,6 +16,7 @@ end local config = Config.resolve({ model = "test-model", + skill_paths = { "custom-skills/SKILL.md" }, custom_backend_option = "preserved", chat = { keys = false }, }) @@ -25,6 +26,7 @@ assert_equal(config.binary, "pi", "default binary") assert_equal(config.thinking, "off", "default thinking level") assert_equal(config.extensions, true, "default extensions") assert_equal(config.skills, false, "default skills") +assert_equal(Config.defaults.skill_paths, {}, "default skill paths") assert_equal(config.reload, true, "default buffer reload") assert_equal(config.auto_close, true, "default auto close") assert_equal(config.model, "test-model", "overridden model") @@ -126,6 +128,7 @@ assert_equal( local backend = Config.backend(config) assert_equal(backend.backend, "pi", "backend name") assert_equal(backend.model, "test-model", "backend model") +assert_equal(backend.skill_paths, { "custom-skills/SKILL.md" }, "backend skill paths") assert_equal(backend.reload, true, "AI buffer reload") assert_equal( backend.custom_backend_option, diff --git a/tests/pi_command_e2e.lua b/tests/pi_command_e2e.lua index 39c9a02..78bdc17 100644 --- a/tests/pi_command_e2e.lua +++ b/tests/pi_command_e2e.lua @@ -2,6 +2,7 @@ local repo = vim.fn.getcwd() vim.opt.runtimepath:prepend(repo) local Command = require("ai.pi.command") +local Config = require("ai.config") local function assert_equal(actual, expected, message) assert( @@ -14,22 +15,37 @@ local function assert_equal(actual, expected, message) ) end -assert_equal(Command.build({ +assert_equal(Command.build(Config.backend()), { + "pi", + "--mode", + "rpc", + "--no-session", + "--no-skills", + "--thinking", + "off", +}, "defaults select no skills and append no custom prompt") + +assert_equal(Command.build(Config.backend({ binary = "~/bin/pi", extensions = false, skills = false, + skill_paths = { "~/skills/writing/SKILL.md", "project skills" }, provider = "test-provider", model = "test-model", thinking = "high", system_prompt = "system prompt", - append_system_prompt = { "first append", "second append" }, -}), { + append_system_prompt = { "Keep $HOME and {{name}} literal", "~/prompts/style.md" }, +})), { vim.fn.expand("~/bin/pi"), "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills", + "--skill", + vim.fn.expand("~/skills/writing/SKILL.md"), + "--skill", + "project skills", "--provider", "test-provider", "--model", @@ -39,26 +55,86 @@ assert_equal(Command.build({ "--system-prompt", "system prompt", "--append-system-prompt", - "first append", - "--append-system-prompt", - "second append", -}, "Pi command") + "Keep $HOME and {{name}} literal\n\n~/prompts/style.md", +}, "Pi command uses configured skills and prompts") assert_equal(Command.build({ binary = "pi", extensions = true, skills = true, - append_system_prompt = "single append", + skill_paths = { "extra-skills" }, + append_system_prompt = "/tmp/writing instructions.md", }), { "pi", "--mode", "rpc", "--no-session", + "--skill", + "extra-skills", "--append-system-prompt", - "single append", + "/tmp/writing instructions.md", }, "Pi command with optional features") +local literal_skill_paths = { + "#notes/SKILL.md", + "%notes/SKILL.md", + "skills/$HOME/SKILL.md", + "lua/ai/*.lua", + "lua/ai/[cp]*.lua", +} +assert_equal(Command.build({ + binary = "pi", + extensions = true, + skills = true, + skill_paths = literal_skill_paths, +}), { + "pi", + "--mode", + "rpc", + "--no-session", + "--skill", + literal_skill_paths[1], + "--skill", + literal_skill_paths[2], + "--skill", + literal_skill_paths[3], + "--skill", + literal_skill_paths[4], + "--skill", + literal_skill_paths[5], +}, "Pi command preserves literal skill paths") + +for _, paths in ipairs({ "skills", false, { named = "skills" }, { [2] = "skills" } }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = paths }) + assert_equal(ok, false, "invalid skill_paths validation") + assert( + tostring(err):find("skill_paths must be a list of paths", 1, true), + "invalid skill_paths error" + ) +end + +assert_equal( + Command.build(Config.backend({ skill_paths = { "", "local-skills", "" } })), + Command.build(Config.backend({ skill_paths = { "local-skills" } })), + "empty skill paths are ignored" +) + +for _, path in ipairs({ false, 42 }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = { path } }) + assert_equal(ok, false, "invalid skill path validation") + assert( + tostring(err):find("skill_paths entries must be strings", 1, true), + "invalid skill path error" + ) +end + local command_override = { "custom-pi", "--custom" } +for _, url in ipairs({ "https://example.com/SKILL.md", "http://example.com/SKILL.md" }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = { url } }) + assert_equal(ok, false, "skill URL validation") + assert(tostring(err):find("skill_paths entries must be local paths", 1, true), "skill URL error") +end + local overridden_command = Command.build({ cmd = command_override }) assert_equal(overridden_command, command_override, "Pi command override") assert(overridden_command ~= command_override, "Pi command override was not copied") diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua new file mode 100644 index 0000000..84ef330 --- /dev/null +++ b/tests/prompts_e2e.lua @@ -0,0 +1,279 @@ +local repo = vim.fn.getcwd() +local snacks = vim.env.SNACKS_NVIM + or (vim.fn.stdpath("data") .. "/lazy/snacks.nvim") +vim.opt.runtimepath:prepend(snacks) +vim.opt.runtimepath:prepend(repo) +vim.cmd.runtime("plugin/ai.nvim.lua") + +local Plugin = require("ai") +local Session = require("ai.session") +local root = vim.fn.tempname() .. " prompts" +vim.fn.mkdir(root, "p") +local missing = root .. "/missing.md" +local instructions = root .. "/instructions.md" +vim.fn.writefile({ "File instructions with $HOME, {{name}}, and Unicode: 台灣." }, instructions) +local empty = root .. "/empty.md" +vim.fn.writefile({}, empty) + +local function read(path) + return table.concat(vim.fn.readfile(path, "b"), "\n") +end + +-- Read prompt arguments once, matching Pi's file-input behavior. +local binary = root .. "/pi" +vim.fn.writefile(vim.split([=[#!/bin/sh +: > "$AI_PROMPTS_OUTPUT" +: > "$AI_PROMPTS_SYSTEM" +while [ "$#" -gt 0 ]; do + if [ "$1" = --append-system-prompt ]; then + cat "$2" >> "$AI_PROMPTS_OUTPUT" || exit 1 + shift + elif [ "$1" = --system-prompt ]; then + cat "$2" >> "$AI_PROMPTS_SYSTEM" || exit 1 + shift + fi + shift +done +printf ready > "$AI_PROMPTS_READY" +IFS= read -r release +exit 0 +]=], "\n", { plain = true }), binary) +assert(vim.uv.fs_chmod(binary, 493)) +vim.env.AI_PROMPTS_OUTPUT = root .. "/output" +vim.env.AI_PROMPTS_SYSTEM = root .. "/system" +vim.env.AI_PROMPTS_READY = root .. "/ready" + +-- Observe paths while still using the real process launcher and filesystem. +local system = vim.system +local prompt_paths = {} +vim.system = function(command, ...) + prompt_paths = {} + for i, arg in ipairs(command) do + if arg == "--append-system-prompt" or arg == "--system-prompt" then + prompt_paths[#prompt_paths + 1] = command[i + 1] + end + end + return system(command, ...) +end + +local function assert_removed(paths) + for _, path in ipairs(paths) do + assert(not vim.uv.fs_stat(path), "temporary prompt file was not removed: " .. path) + end +end + +local function launch(opts, expected, expected_system) + vim.fn.delete(vim.env.AI_PROMPTS_READY) + Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) + vim.cmd("AI") + local session = assert(Session.get_current()) + assert(vim.wait(2000, function() + return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 + end), "backend did not read its prompt") + assert(read(vim.env.AI_PROMPTS_OUTPUT) == expected, "backend received incorrect instructions") + assert(read(vim.env.AI_PROMPTS_SYSTEM) == (expected_system or ""), "backend received incorrect system prompt") + local owned_paths = {} + for _, path in ipairs(prompt_paths) do + if path ~= opts.system_prompt then + local stat = assert(vim.uv.fs_stat(path), "prompt file vanished while the backend was alive") + assert(bit.band(stat.mode, 511) == 384, "temporary prompt file was not owner-only") + assert(path:sub(1, 1) == "/", "temporary prompt path was not absolute") + owned_paths[#owned_paths + 1] = path + end + end + session.ai:finish() + assert(vim.wait(2000, function() return session.ai.result ~= nil end), "backend did not exit") + assert_removed(owned_paths) + assert(Session.stop_current()) +end + +local notifications = {} +local notify = vim.notify +local notification_levels = {} +local startup_prefix = "Failed to start AI backend: " +vim.notify = function(message, level) + notifications[#notifications + 1] = message + notification_levels[#notification_levels + 1] = level +end + +-- Configuration is stored at setup; startup failures use the existing notification. +local function startup_error(opts, expected) + Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) + local before = #vim.api.nvim_list_bufs() + local count = #notifications + prompt_paths = {} + vim.cmd("AI") + assert(Session.get_current() == nil, "invalid prompt configuration opened a session") + assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") + assert(#notifications == count + 1, "startup did not report one error") + assert(notification_levels[#notification_levels] == vim.log.levels.ERROR, "startup notification was not an error") + local message = notifications[#notifications] + assert(message:sub(1, #startup_prefix) == startup_prefix, "startup notification was bypassed") + assert(message:find(expected, #startup_prefix + 1, true), "startup notification omitted the error") + for _, path in ipairs(prompt_paths) do + if path ~= opts.system_prompt then + assert_removed({ path }) + end + end + return message:sub(#startup_prefix + 1) +end + +-- Prompts larger than the real OS argument limit now start through :AI. +local large_prompt = string.rep("Keep responses concise.\n", 50000) +local large_file = root .. "/large.md" +vim.fn.writefile({ large_prompt }, large_file, "b") +launch({ append_system_prompt = { large_prompt, large_prompt } }, large_prompt .. "\n\n" .. large_prompt) +launch({ append_system_prompt_filepath = large_file }, large_prompt) +launch({ system_prompt = large_prompt }, "", large_prompt) + +-- Preserve the original diagnostics for custom commands and unrelated failures. +local raw_e2big = startup_error({ model = large_prompt }, "E2BIG:") +startup_error({ cmd = { binary, large_prompt } }, raw_e2big) +startup_error({ model = large_prompt, append_system_prompt_filepath = empty }, raw_e2big) +startup_error({ model = large_prompt, append_system_prompt = "short" }, raw_e2big) +startup_error({ binary = missing }, "ENOENT:") +startup_error({ binary = missing, system_prompt = "Base instructions.", append_system_prompt = "Extra instructions." }, "ENOENT:") +startup_error({ binary = missing, system_prompt = instructions, append_system_prompt = "Extra instructions." }, "ENOENT:") +assert(vim.fn.filereadable(instructions) == 1, "failed startup deleted the user's prompt file") + +-- Fail the second file operation after one prompt file has already been prepared. +for _, operation in ipairs({ "create", "write", "close" }) do + local fs_open, fs_write, fs_close = vim.uv.fs_open, vim.uv.fs_write, vim.uv.fs_close + local paths, second_fd = {}, nil + local attempts = 0 + vim.uv.fs_open = function(path, flags, mode) + if flags == "wx" then + attempts = attempts + 1 + if attempts == 2 and operation == "create" then + return nil, "EACCES: permission denied", "EACCES" + end + end + local fd, err, code = fs_open(path, flags, mode) + if flags == "wx" and fd then + paths[#paths + 1] = path + if attempts == 2 then second_fd = fd end + end + return fd, err, code + end + vim.uv.fs_write = function(fd, ...) + if fd == second_fd and operation == "write" then + return nil, "ENOSPC: no space left on device", "ENOSPC" + end + return fs_write(fd, ...) + end + vim.uv.fs_close = function(fd) + if fd == second_fd and operation == "close" then + second_fd = nil + fs_close(fd) + return nil, "EIO: input/output error", "EIO" + end + return fs_close(fd) + end + local ok, err = pcall(startup_error, { + system_prompt = "Base instructions.", + append_system_prompt = "Extra instructions.", + }, "Could not " .. operation .. " temporary system prompt file") + vim.uv.fs_open, vim.uv.fs_write, vim.uv.fs_close = fs_open, fs_write, fs_close + assert(ok, err) + assert_removed(paths) +end + +-- Short writes must preserve every byte, including multibyte characters. +local fs_write = vim.uv.fs_write +vim.uv.fs_write = function(fd, text, offset) + return fs_write(fd, text:sub(1, 7), offset) +end +local partial_text = "First line.\n台灣.\nFinal line." +local partial_ok, partial_err = pcall(launch, { append_system_prompt = partial_text }, partial_text) +vim.uv.fs_write = fs_write +assert(partial_ok, partial_err) + +startup_error({ append_system_prompt_filepath = missing }, missing) +assert(notifications[#notifications]:find("append_system_prompt_filepath", 1, true), "error omitted the option name") + +for _, value in ipairs({ root, "", false, 42, { instructions, false }, { named = instructions }, { [2] = instructions } }) do + startup_error({ append_system_prompt_filepath = value }, "append_system_prompt_filepath") +end +for _, value in ipairs({ false, 42, { "valid", false }, { named = "invalid" } }) do + startup_error({ append_system_prompt = value }, "append_system_prompt") +end + +for _, url in ipairs({ "https://example.com/instructions.md", "http://example.com/instructions.md" }) do + startup_error({ append_system_prompt_filepath = url }, "must be a local path") +end + +if vim.uv.getuid and vim.uv.getuid() ~= 0 then + assert(vim.uv.fs_chmod(instructions, 0)) + startup_error({ append_system_prompt_filepath = instructions }, instructions) + assert(vim.uv.fs_chmod(instructions, 384)) +end + +launch({ append_system_prompt = "Keep responses concise." }, "Keep responses concise.") +launch({ append_system_prompt = "~/keep-this-literal.md" }, "~/keep-this-literal.md") +launch({ append_system_prompt = "https://example.com/literal.md" }, "https://example.com/literal.md") +launch({ append_system_prompt_filepath = instructions }, read(instructions)) +launch({ system_prompt = instructions }, "", read(instructions)) +assert(vim.fn.filereadable(instructions) == 1, "the user's system prompt file was deleted") +launch({ cmd = { binary }, append_system_prompt = large_prompt }, "") + +local other = root .. "/other.md" +vim.fn.writefile({ instructions }, other, "b") +launch({ append_system_prompt = instructions }, instructions) +launch({ append_system_prompt_filepath = other }, instructions) +launch({ + append_system_prompt = { "First instruction.", "Second instruction." }, + append_system_prompt_filepath = { instructions, other }, +}, "First instruction.\n\nSecond instruction.\n\n" .. read(instructions) .. "\n\n" .. instructions) + +launch({ append_system_prompt = "", append_system_prompt_filepath = empty }, "") + +-- Setup can precede a directory change; relative paths use the session directory. +Plugin.setup({ binary = binary, append_system_prompt_filepath = "instructions.md" }) +vim.api.nvim_set_current_dir(root) +vim.fn.delete(vim.env.AI_PROMPTS_READY) +vim.cmd("AI") +local session = assert(Session.get_current()) +assert(vim.wait(2000, function() + return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 +end), "relative-path backend did not start") +assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "relative path used the setup directory") +local previous_prompt_paths = vim.deepcopy(prompt_paths) + +local function new_session_key() + Session.get_current().chat.input:focus() + vim.cmd("startinsert") + vim.api.nvim_feedkeys(vim.keycode(""), "xt", false) +end + +-- Ctrl-N reports missing files and preserves the current session on failure. +vim.fn.delete(instructions) +local before = #vim.api.nvim_list_bufs() +local count = #notifications +new_session_key() +assert(vim.wait(2000, function() return #notifications > count end), "Ctrl-N did not report the missing file") +assert(notifications[#notifications]:find(instructions, 1, true), "Ctrl-N notification omitted the missing file") +assert(Session.get_current() == session, "failed Ctrl-N replaced the current session") +assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") +assert(vim.fn.filereadable(previous_prompt_paths[1]) == 1, "failed replacement removed the active prompt file") + +-- A subsequent Ctrl-N reads the updated file without another setup() call. +vim.fn.writefile({ "Updated instructions." }, instructions) +vim.fn.delete(vim.env.AI_PROMPTS_READY) +new_session_key() +assert(vim.wait(2000, function() + return Session.get_current() ~= session and vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 +end), "Ctrl-N did not start a new session") +assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "Ctrl-N did not reread the prompt file") +assert_removed(previous_prompt_paths) +local current_prompt_paths = vim.deepcopy(prompt_paths) +assert(Session.stop_current()) +assert_removed(current_prompt_paths) + +startup_error({ append_system_prompt_filepath = "missing.md" }, missing) +vim.notify = notify +vim.system = system + +vim.api.nvim_set_current_dir(repo) +vim.fn.delete(root, "rf") +print("Prompt configuration E2E checks passed") +vim.cmd("qa!")