From f0bcef903da38890400ee51e51d4a68c6008d742 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Fri, 21 Aug 2026 13:04:24 +0800 Subject: [PATCH] refactor: separate plugin responsibilities --- lua/patch/{ => core}/context.lua | 2 +- lua/patch/core/generation.lua | 62 ++++++ lua/patch/{ => core}/replacement.lua | 2 +- lua/patch/{ => core}/selection.lua | 0 lua/patch/core/workflow.lua | 271 ++++++++++++++++++++++++ lua/patch/init.lua | 277 ++----------------------- lua/patch/{ => pi}/client.lua | 2 +- lua/patch/{ => pi}/prompt.lua | 0 lua/patch/{ => pi}/rpc.lua | 0 lua/patch/{ => ui}/notify.lua | 0 tests/patch/test_client.lua | 10 +- tests/patch/test_context.lua | 4 +- tests/patch/test_generation.lua | 116 +++++++++++ tests/patch/test_init.lua | 297 ++++----------------------- tests/patch/test_notify.lua | 4 +- tests/patch/test_prompt.lua | 2 +- tests/patch/test_replacement.lua | 4 +- tests/patch/test_rpc.lua | 4 +- tests/patch/test_selection.lua | 2 +- tests/patch/test_workflow.lua | 279 +++++++++++++++++++++++++ 20 files changed, 804 insertions(+), 534 deletions(-) rename lua/patch/{ => core}/context.lua (93%) create mode 100644 lua/patch/core/generation.lua rename lua/patch/{ => core}/replacement.lua (99%) rename lua/patch/{ => core}/selection.lua (100%) create mode 100644 lua/patch/core/workflow.lua rename lua/patch/{ => pi}/client.lua (99%) rename lua/patch/{ => pi}/prompt.lua (100%) rename lua/patch/{ => pi}/rpc.lua (100%) rename lua/patch/{ => ui}/notify.lua (100%) create mode 100644 tests/patch/test_generation.lua create mode 100644 tests/patch/test_workflow.lua diff --git a/lua/patch/context.lua b/lua/patch/core/context.lua similarity index 93% rename from lua/patch/context.lua rename to lua/patch/core/context.lua index dba2526..2249a18 100644 --- a/lua/patch/context.lua +++ b/lua/patch/core/context.lua @@ -1,6 +1,6 @@ local M = {} -local selection = require("patch.selection") +local selection = require("patch.core.selection") --- @class PatchContent --- @field before string[] lines before the selection diff --git a/lua/patch/core/generation.lua b/lua/patch/core/generation.lua new file mode 100644 index 0000000..e1150c2 --- /dev/null +++ b/lua/patch/core/generation.lua @@ -0,0 +1,62 @@ +local client = require("patch.pi.client") +local prompt = require("patch.pi.prompt") + +local M = {} + +---@class PatchGenerationCallbacks +---@field is_current fun(request: PatchRequest): boolean +---@field apply_response fun(response: string): PatchProposal|nil, string|nil +---@field on_failure fun(err: string, kind: "request"|"application") +---@field on_complete fun() + +---Build a generation prompt from captured content and an instruction. +--- +---@param content PatchContent +---@param instruction string +---@return string message +function M.build(content, instruction) + return prompt.build(content, instruction) +end + +---Run a generation request and return the workflow to review when it succeeds. +--- +---@param workflow PatchWorkflow +---@param phase "generating"|"retrying" +---@param callbacks PatchGenerationCallbacks +function M.run(workflow, phase, callbacks) + workflow.phase = phase + + local request + request = client.request(workflow.message, function(response, err) + if not callbacks.is_current(request) then + return + end + + workflow.request = nil + + if err then + callbacks.on_failure(err, "request") + return + end + + local completed, completion_error = callbacks.apply_response(response) + if not completed then + callbacks.on_failure(tostring(completion_error), "application") + return + end + + workflow.phase = "reviewing" + callbacks.on_complete() + end) + workflow.request = request +end + +---Cancel an active generation request. +--- +---@param request PatchRequest +---@return boolean cancelled +function M.cancel(request) + return client.cancel(request) +end + +return M diff --git a/lua/patch/replacement.lua b/lua/patch/core/replacement.lua similarity index 99% rename from lua/patch/replacement.lua rename to lua/patch/core/replacement.lua index 5824145..50d1803 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/core/replacement.lua @@ -1,5 +1,5 @@ local M = {} -local selection = require("patch.selection") +local selection = require("patch.core.selection") local namespace = vim.api.nvim_create_namespace("patch-replacement") local previews = {} diff --git a/lua/patch/selection.lua b/lua/patch/core/selection.lua similarity index 100% rename from lua/patch/selection.lua rename to lua/patch/core/selection.lua diff --git a/lua/patch/core/workflow.lua b/lua/patch/core/workflow.lua new file mode 100644 index 0000000..507eba3 --- /dev/null +++ b/lua/patch/core/workflow.lua @@ -0,0 +1,271 @@ +local generation = require("patch.core.generation") +local selection = require("patch.core.selection") +local context = require("patch.core.context") +local client = require("patch.pi.client") +local replacement = require("patch.core.replacement") +local ui = require("patch.ui") +local notify = require("patch.ui.notify") + +local M = {} + +---@class PatchWorkflow +---@field phase "input"|"generating"|"reviewing"|"retrying" +---@field location PatchLocation +---@field content? PatchContent +---@field message? string +---@field request? PatchRequest +---@field proposal? PatchProposal + +---@type PatchWorkflow|nil +local active_workflow + +---@param message string +local function warn(message) + notify.send("patch: " .. message, vim.log.levels.WARN) +end + +---@param message string +local function report_error(message) + notify.send("patch: " .. message, vim.log.levels.ERROR) +end + +---@param message string +local function report_info(message) + notify.send("patch: " .. message, vim.log.levels.INFO) +end + +---@param err string|nil +local function report_request_error(err) + if err and err ~= "cancelled" then + report_error(err) + end +end + +---@return boolean blocked +local function block_menu_for_active_input() + if not active_workflow or active_workflow.phase ~= "input" then + return false + end + + warn("submit or close the active instruction first") + return true +end + +--- Open a menu containing the models available to Pi. +function M.open_menu() + if block_menu_for_active_input() then + return + end + + client.resolve_model(function(selected_model, model_error) + if block_menu_for_active_input() then + return + end + + if model_error then + report_error(model_error) + return + end + + client.get_available_models(function(models, err) + if block_menu_for_active_input() then + return + end + + if err then + report_error(err) + return + end + + if not models or #models == 0 then + warn("Pi reported no available models") + return + end + + ui.open_menu(models, selected_model, function(model) + local selected = client.select_model(model) + report_info("using " .. selected) + end) + end) + end) +end + +---@return boolean blocked +local function block_start_for_active_workflow() + if not active_workflow then + return false + end + + if active_workflow.phase == "input" then + warn("submit or close the active instruction first") + elseif active_workflow.phase == "reviewing" then + warn("accept or reject the active proposal first") + else + warn("a replacement is already being generated") + end + + return true +end + +--- Run a generation request and return the workflow to review when it succeeds. +--- +---@param workflow PatchWorkflow +---@param phase "generating"|"retrying" +---@param apply_response fun(response: string): PatchProposal|nil, string|nil +---@param abort fun() +local function generate(workflow, phase, apply_response, abort) + report_info("generating...") + + generation.run(workflow, phase, { + is_current = function(request) + return active_workflow == workflow + and workflow.phase == phase + and workflow.request == request + end, + apply_response = apply_response, + on_failure = function(err, kind) + abort() + active_workflow = nil + + if kind == "request" then + report_request_error(err) + else + report_error(err) + end + end, + on_complete = function() + report_info("complete") + end, + }) +end + +--- Capture a visual selection, request a replacement, and preview it at the tracked range. +function M.start() + if block_start_for_active_workflow() then + return + end + + local location = selection.capture() + if not location then + warn("no selection found") + return + end + + local content = context.capture(location) + if not content then + selection.clear(location) + warn("no selection found") + return + end + + local workflow = { + phase = "input", + location = location, + content = content, + } + active_workflow = workflow + + ui.open_input(function(instruction) + if active_workflow ~= workflow or workflow.phase ~= "input" then + return + end + + workflow.message = generation.build(workflow.content, instruction) + workflow.content = nil + + generate(workflow, "generating", function(response) + local proposal = replacement.apply(workflow.location, response) + if not proposal then + return nil, "selection no longer exists" + end + + workflow.proposal = proposal + return proposal + end, function() + selection.clear(workflow.location) + end) + end, function() + if active_workflow ~= workflow or workflow.phase ~= "input" then + return + end + + active_workflow = nil + selection.clear(workflow.location) + end) +end + +--- Accept the active replacement proposal. +function M.accept() + local workflow = active_workflow + if not workflow or not workflow.proposal then + warn("no active proposal to accept") + return + end + + if replacement.accept(workflow.proposal) then + active_workflow = nil + return + end + + if workflow.proposal.status == "finished" then + -- The proposal was finalized because its source buffer is unavailable. + active_workflow = nil + end + + warn("the active proposal cannot be accepted") +end + +--- Reject the active replacement proposal. +function M.reject() + local workflow = active_workflow + if not workflow or not workflow.proposal then + warn("no active proposal to reject") + return + end + + if replacement.reject(workflow.proposal) then + active_workflow = nil + else + warn("the active proposal cannot be rejected") + end +end + +--- Request another replacement for the active proposal. +function M.retry() + local workflow = active_workflow + if not workflow or not workflow.proposal or not workflow.message then + warn("no active proposal to retry") + return + end + + if not replacement.begin_retry(workflow.proposal) then + if workflow.phase == "retrying" then + warn("the active proposal cannot be retried") + return + end + + -- The proposal was finalized because its source buffer is unavailable. + active_workflow = nil + warn("the active proposal cannot be retried") + return + end + + generate(workflow, "retrying", function(response) + return replacement.complete_retry(workflow.proposal, response) + end, function() + replacement.abort_retry(workflow.proposal) + end) +end + +--- Cancel the active replacement request, if one exists. +function M.cancel() + local workflow = active_workflow + if not workflow or not workflow.request or not generation.cancel(workflow.request) then + warn("nothing to cancel") + return + end + + report_info("cancelled") +end + +return M diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 67e30b6..383b0b3 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,24 +1,9 @@ -local prompt = require("patch.prompt") -local selection = require("patch.selection") -local context = require("patch.context") -local client = require("patch.client") -local replacement = require("patch.replacement") -local ui = require("patch.ui") -local notify = require("patch.notify") +local workflow = require("patch.core.workflow") +local client = require("patch.pi.client") +local notify = require("patch.ui.notify") local M = {} ----@class PatchWorkflow ----@field phase "input"|"generating"|"reviewing"|"retrying" ----@field location PatchLocation ----@field content? PatchContent ----@field message? string ----@field request? PatchRequest ----@field proposal? PatchProposal - ----@type PatchWorkflow|nil -local active_workflow - ---@class PatchOptions ---@field notify? function|table notification provider compatible with vim.notify ---@field system_prompt? string custom Pi system prompt @@ -34,262 +19,34 @@ function M.setup(opts) }) end ----@param message string -local function warn(message) - notify.send("patch: " .. message, vim.log.levels.WARN) -end - ----@param message string -local function report_error(message) - notify.send("patch: " .. message, vim.log.levels.ERROR) -end - ----@param message string -local function report_info(message) - notify.send("patch: " .. message, vim.log.levels.INFO) -end - ----@param err string|nil -local function report_request_error(err) - if err and err ~= "cancelled" then - report_error(err) - end -end - ----@return boolean blocked -local function block_menu_for_active_input() - if not active_workflow or active_workflow.phase ~= "input" then - return false - end - - warn("submit or close the active instruction first") - return true -end - ---- Open a menu containing the models available to Pi. -function M.open_menu() - if block_menu_for_active_input() then - return - end - - client.resolve_model(function(selected_model, model_error) - if block_menu_for_active_input() then - return - end - - if model_error then - report_error(model_error) - return - end - - client.get_available_models(function(models, err) - if block_menu_for_active_input() then - return - end - - if err then - report_error(err) - return - end - - if not models or #models == 0 then - warn("Pi reported no available models") - return - end - - ui.open_menu(models, selected_model, function(model) - local selected = client.select_model(model) - report_info("using " .. selected) - end) - end) - end) -end - ----@return boolean blocked -local function block_start_for_active_workflow() - if not active_workflow then - return false - end - - if active_workflow.phase == "input" then - warn("submit or close the active instruction first") - elseif active_workflow.phase == "reviewing" then - warn("accept or reject the active proposal first") - else - warn("a replacement is already being generated") - end - - return true -end - ---- Run a generation request and return the workflow to review when it succeeds. ---- ----@param workflow PatchWorkflow ----@param phase "generating"|"retrying" ----@param apply_response fun(response: string): PatchProposal|nil, string|nil ----@param abort fun() -local function generate(workflow, phase, apply_response, abort) - workflow.phase = phase - report_info("generating...") - - local request - request = client.request(workflow.message, function(response, err) - if active_workflow ~= workflow - or workflow.phase ~= phase - or workflow.request ~= request then - return - end - - workflow.request = nil - - if err then - abort() - active_workflow = nil - report_request_error(err) - return - end - - local completed, completion_error = apply_response(response) - if not completed then - abort() - active_workflow = nil - report_error(tostring(completion_error)) - return - end - - workflow.phase = "reviewing" - report_info("complete") - end) - workflow.request = request -end - ---- Capture a visual selection, request a replacement, and preview it at the tracked range. +---Capture a visual selection and request a replacement. function M.start() - if block_start_for_active_workflow() then - return - end - - local location = selection.capture() - if not location then - warn("no selection found") - return - end - - local content = context.capture(location) - if not content then - selection.clear(location) - warn("no selection found") - return - end - - local workflow = { - phase = "input", - location = location, - content = content, - } - active_workflow = workflow - - ui.open_input(function(instruction) - if active_workflow ~= workflow or workflow.phase ~= "input" then - return - end - - workflow.message = prompt.build(workflow.content, instruction) - workflow.content = nil - - generate(workflow, "generating", function(response) - local proposal = replacement.apply(workflow.location, response) - if not proposal then - return nil, "selection no longer exists" - end - - workflow.proposal = proposal - return proposal - end, function() - selection.clear(workflow.location) - end) - end, function() - if active_workflow ~= workflow or workflow.phase ~= "input" then - return - end - - active_workflow = nil - selection.clear(workflow.location) - end) + workflow.start() end ---- Accept the active replacement proposal. +---Accept the active replacement proposal. function M.accept() - local workflow = active_workflow - if not workflow or not workflow.proposal then - warn("no active proposal to accept") - return - end - - if replacement.accept(workflow.proposal) then - active_workflow = nil - return - end - - if workflow.proposal.status == "finished" then - -- The proposal was finalized because its source buffer is unavailable. - active_workflow = nil - end - - warn("the active proposal cannot be accepted") + workflow.accept() end ---- Reject the active replacement proposal. +---Reject the active replacement proposal. function M.reject() - local workflow = active_workflow - if not workflow or not workflow.proposal then - warn("no active proposal to reject") - return - end - - if replacement.reject(workflow.proposal) then - active_workflow = nil - else - warn("the active proposal cannot be rejected") - end + workflow.reject() end ---- Request another replacement for the active proposal. +---Request another replacement for the active proposal. function M.retry() - local workflow = active_workflow - if not workflow or not workflow.proposal or not workflow.message then - warn("no active proposal to retry") - return - end - - if not replacement.begin_retry(workflow.proposal) then - if workflow.phase == "retrying" then - warn("the active proposal cannot be retried") - return - end - - -- The proposal was finalized because its source buffer is unavailable. - active_workflow = nil - warn("the active proposal cannot be retried") - return - end - - generate(workflow, "retrying", function(response) - return replacement.complete_retry(workflow.proposal, response) - end, function() - replacement.abort_retry(workflow.proposal) - end) + workflow.retry() end ---- Cancel the active replacement request, if one exists. +---Cancel the active generation request. function M.cancel() - local workflow = active_workflow - if not workflow or not workflow.request or not client.cancel(workflow.request) then - warn("nothing to cancel") - return - end + workflow.cancel() +end - report_info("cancelled") +---Open the Pi model selection menu. +function M.open_menu() + workflow.open_menu() end return M diff --git a/lua/patch/client.lua b/lua/patch/pi/client.lua similarity index 99% rename from lua/patch/client.lua rename to lua/patch/pi/client.lua index c48ed90..98936b0 100644 --- a/lua/patch/client.lua +++ b/lua/patch/pi/client.lua @@ -1,5 +1,5 @@ local M = {} -local rpc = require("patch.rpc") +local rpc = require("patch.pi.rpc") local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences.\n" local system_prompt = DEFAULT_SYSTEM_PROMPT diff --git a/lua/patch/prompt.lua b/lua/patch/pi/prompt.lua similarity index 100% rename from lua/patch/prompt.lua rename to lua/patch/pi/prompt.lua diff --git a/lua/patch/rpc.lua b/lua/patch/pi/rpc.lua similarity index 100% rename from lua/patch/rpc.lua rename to lua/patch/pi/rpc.lua diff --git a/lua/patch/notify.lua b/lua/patch/ui/notify.lua similarity index 100% rename from lua/patch/notify.lua rename to lua/patch/ui/notify.lua diff --git a/tests/patch/test_client.lua b/tests/patch/test_client.lua index 236222e..46ae282 100644 --- a/tests/patch/test_client.lua +++ b/tests/patch/test_client.lua @@ -43,15 +43,15 @@ local function reset() end, } - package.loaded["patch.rpc"] = rpc - package.loaded["patch.client"] = nil - client = require("patch.client") + package.loaded["patch.pi.rpc"] = rpc + package.loaded["patch.pi.client"] = nil + client = require("patch.pi.client") client.setup() end local function cleanup() - package.loaded["patch.client"] = nil - package.loaded["patch.rpc"] = nil + package.loaded["patch.pi.client"] = nil + package.loaded["patch.pi.rpc"] = nil end local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) diff --git a/tests/patch/test_context.lua b/tests/patch/test_context.lua index 9abdd95..4110813 100644 --- a/tests/patch/test_context.lua +++ b/tests/patch/test_context.lua @@ -1,6 +1,6 @@ local helpers = require("tests.helpers") -local context = require("patch.context") -local selection = require("patch.selection") +local context = require("patch.core.context") +local selection = require("patch.core.selection") local eq = MiniTest.expect.equality local buffer diff --git a/tests/patch/test_generation.lua b/tests/patch/test_generation.lua new file mode 100644 index 0000000..0c42d89 --- /dev/null +++ b/tests/patch/test_generation.lua @@ -0,0 +1,116 @@ +local eq = MiniTest.expect.equality +local generation +local requests + +local function reset() + requests = {} + + package.loaded["patch.pi.client"] = { + request = function(message, on_complete) + local request = { message = message, on_complete = on_complete } + table.insert(requests, request) + return request + end, + cancel = function(request) + request.cancelled = true + return true + end, + } + package.loaded["patch.pi.prompt"] = { + build = function(_, instruction) + return "built:" .. instruction + end, + } + package.loaded["patch.core.generation"] = nil + generation = require("patch.core.generation") +end + +local function cleanup() + for _, name in ipairs({ + "patch.core.generation", "patch.pi.client", "patch.pi.prompt", + }) do + package.loaded[name] = nil + end +end + +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) + +T["builds prompts and completes current requests"] = function() + local workflow = { phase = "input", message = "message" } + local applied + local completed = false + + eq(generation.build({}, "change it"), "built:change it") + generation.run(workflow, "generating", { + is_current = function(request) + return workflow.request == request + end, + apply_response = function(response) + applied = response + return {} + end, + on_failure = function() error("unexpected failure") end, + on_complete = function() completed = true end, + }) + + eq(workflow.phase, "generating") + eq(requests[1].message, "message") + requests[1].on_complete("replacement", nil) + + eq(applied, "replacement") + eq(workflow.request, nil) + eq(workflow.phase, "reviewing") + eq(completed, true) +end + +T["reports request and application failures"] = function() + local workflow = { phase = "input", message = "message" } + local failures = {} + local apply_result = nil + local apply_error = "invalid replacement" + + local function run() + generation.run(workflow, "retrying", { + is_current = function() return true end, + apply_response = function() return apply_result, apply_error end, + on_failure = function(err, kind) + table.insert(failures, { err, kind }) + end, + on_complete = function() error("unexpected completion") end, + }) + end + + run() + requests[1].on_complete(nil, "request failed") + eq(failures[1], { "request failed", "request" }) + + run() + requests[2].on_complete("replacement", nil) + eq(failures[2], { "invalid replacement", "application" }) +end + +T["ignores stale completions"] = function() + local workflow = { phase = "input", message = "message" } + local called = false + + generation.run(workflow, "generating", { + is_current = function() return false end, + apply_response = function() called = true end, + on_failure = function() called = true end, + on_complete = function() called = true end, + }) + requests[1].on_complete("replacement", nil) + + eq(called, false) + eq(workflow.request, requests[1]) + eq(workflow.phase, "generating") +end + +T["delegates cancellation"] = function() + local request = {} + + eq(generation.cancel(request), true) + eq(request.cancelled, true) +end + +return T diff --git a/tests/patch/test_init.lua b/tests/patch/test_init.lua index df043bb..ca04555 100644 --- a/tests/patch/test_init.lua +++ b/tests/patch/test_init.lua @@ -1,285 +1,70 @@ local eq = MiniTest.expect.equality local patch -local mocks -local notifications -local input -local requests -local proposal -local location +local calls +local client +local notify local function reset() - notifications = {} - requests = {} - proposal = { status = "pending" } - location = { source_buf = 1, extmark_id = 2 } - - mocks = { - prompt = { - build = function(_, instruction) - return "built:" .. instruction - end, - }, - selection = { - capture = function() - return location - end, - clear = function(value) - mocks.selection.cleared = value - end, - }, - context = { - capture = function(value) - eq(value, location) - return { before = {}, selected = { "old" }, after = {} } - end, - }, - client = { - setup = function(options) - mocks.client.setup_options = options - end, - request = function(message, on_complete) - local request = { message = message, on_complete = on_complete, state = "pending" } - table.insert(requests, request) - return request - end, - cancel = function(request) - request.cancelled = true - return true - end, - resolve_model = function(on_complete) - on_complete("provider/current", nil) - end, - get_available_models = function(on_complete) - on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil) - end, - select_model = function(model) - return model.provider .. "/" .. model.id - end, - }, - replacement = { - apply = function(value, response) - mocks.replacement.applied = { value, response } - proposal.location = value - return proposal - end, - accept = function(value) - mocks.replacement.accepted = value - return true - end, - reject = function(value) - mocks.replacement.rejected = value - return true - end, - begin_retry = function(value) - mocks.replacement.retry_started = value - value.status = "retrying" - return true - end, - complete_retry = function(value, response) - mocks.replacement.retry_completed = { value, response } - value.status = "pending" - return value - end, - abort_retry = function(value) - mocks.replacement.retry_aborted = value - value.status = "finished" - return true - end, - }, - ui = { - open_input = function(on_submit, on_close) - input = { submit = on_submit, close = on_close } - end, - open_menu = function(models, selected, on_submit) - mocks.ui.menu = { models = models, selected = selected, submit = on_submit } - end, - }, - notify = { - setup = function(provider) - mocks.notify.provider = provider - end, - send = function(message, level) - table.insert(notifications, { message = message, level = level }) - end, - }, + calls = {} + client = { + setup = function(options) + calls.client_setup = options + end, + } + notify = { + setup = function(provider) + calls.notify_setup = provider + end, } - package.loaded["patch.prompt"] = mocks.prompt - package.loaded["patch.selection"] = mocks.selection - package.loaded["patch.context"] = mocks.context - package.loaded["patch.client"] = mocks.client - package.loaded["patch.replacement"] = mocks.replacement - package.loaded["patch.ui"] = mocks.ui - package.loaded["patch.notify"] = mocks.notify + package.loaded["patch.core.workflow"] = { + start = function() calls.start = true end, + accept = function() calls.accept = true end, + reject = function() calls.reject = true end, + retry = function() calls.retry = true end, + cancel = function() calls.cancel = true end, + open_menu = function() calls.open_menu = true end, + } + package.loaded["patch.pi.client"] = client + package.loaded["patch.ui.notify"] = notify package.loaded["patch"] = nil patch = require("patch") end local function cleanup() for _, name in ipairs({ - "patch", "patch.prompt", "patch.selection", "patch.context", "patch.client", - "patch.replacement", "patch.ui", "patch.notify", + "patch", "patch.core.workflow", "patch.pi.client", "patch.ui.notify", }) do - package.loaded[name] = nil -end -end - -local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) - -local function test(name, action) - T[name] = action -end - -local function notification(message, level) - for _, item in ipairs(notifications) do - if item.message == message and (not level or item.level == level) then - return item - end + package.loaded[name] = nil end end -local function start_and_submit() - patch.start() - eq(type(input), "table") - input.submit("change it") - eq(#requests, 1) -end - -local function complete_generation(response, err) - requests[#requests].on_complete(response, err) -end +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) -test("passes configuration to notification and client modules", function() +T["passes configuration to notification and client modules"] = function() local provider = function() end patch.setup({ notify = provider, system_prompt = "system", model = "provider/model" }) - eq(mocks.notify.provider, provider) - eq(mocks.client.setup_options, { system_prompt = "system", model = "provider/model" }) -end) - -test("runs capture, generation, review, and acceptance", function() - start_and_submit() - - eq(requests[1].message, "built:change it") - assert(notification("patch: generating...", vim.log.levels.INFO)) - - complete_generation("replacement", nil) - eq(mocks.replacement.applied, { location, "replacement" }) - assert(notification("patch: complete", vim.log.levels.INFO)) - - patch.accept() - eq(mocks.replacement.accepted, proposal) + eq(calls.notify_setup, provider) + eq(calls.client_setup, { system_prompt = "system", model = "provider/model" }) +end +T["delegates public commands to the workflow"] = function() + patch.start() patch.accept() - assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) -end) - -test("rejects a reviewed proposal", function() - start_and_submit() - complete_generation("replacement", nil) - - patch.reject() - - eq(mocks.replacement.rejected, proposal) patch.reject() - assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) -end) - -test("retries a proposal and applies the new response", function() - start_and_submit() - complete_generation("first", nil) - patch.retry() - eq(mocks.replacement.retry_started, proposal) - eq(#requests, 2) - - complete_generation("second", nil) - eq(mocks.replacement.retry_completed, { proposal, "second" }) - assert(notification("patch: complete", vim.log.levels.INFO)) -end) - -test("aborts a failed retry and reports the error", function() - start_and_submit() - complete_generation("first", nil) - patch.retry() - - complete_generation(nil, "request failed") - - eq(mocks.replacement.retry_aborted, proposal) - assert(notification("patch: request failed", vim.log.levels.ERROR)) -end) - -test("cancels an active request and suppresses the cancellation error", function() - start_and_submit() - patch.cancel() - eq(requests[1].cancelled, true) - assert(notification("patch: cancelled", vim.log.levels.INFO)) - - complete_generation(nil, "cancelled") - eq(mocks.selection.cleared, location) - eq(notification("patch: cancelled", vim.log.levels.ERROR), nil) -end) - -test("cleans up when input closes or generation fails", function() - patch.start() - input.close() - eq(mocks.selection.cleared, location) - - mocks.selection.cleared = nil - start_and_submit() - complete_generation(nil, "failed") - eq(mocks.selection.cleared, location) - assert(notification("patch: failed", vim.log.levels.ERROR)) -end) - -test("blocks overlapping workflows", function() - patch.start() - patch.start() - assert(notification("patch: submit or close the active instruction first", vim.log.levels.WARN)) - - input.submit("change") - patch.start() - assert(notification("patch: a replacement is already being generated", vim.log.levels.WARN)) - - complete_generation("replacement", nil) - patch.start() - assert(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN)) -end) - -test("ignores stale completion callbacks", function() - start_and_submit() - local stale = requests[1].on_complete - patch.cancel() - stale(nil, "cancelled") - - patch.start() - input.submit("new change") - stale("stale response", nil) - - eq(mocks.replacement.applied, nil) - eq(#requests, 2) -end) - -test("opens the model menu and records a selection", function() patch.open_menu() - eq(mocks.ui.menu.selected, "provider/current") - eq(mocks.ui.menu.models[1].id, "model") - mocks.ui.menu.submit(mocks.ui.menu.models[1]) - - assert(notification("patch: using provider/model", vim.log.levels.INFO)) -end) - -test("warns when actions have no active workflow", function() - patch.accept() - patch.reject() - patch.retry() - patch.cancel() + eq(calls, { + start = true, + accept = true, + reject = true, + retry = true, + cancel = true, + open_menu = true, + }) +end - assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) - assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) - assert(notification("patch: no active proposal to retry", vim.log.levels.WARN)) - assert(notification("patch: nothing to cancel", vim.log.levels.WARN)) -end) return T diff --git a/tests/patch/test_notify.lua b/tests/patch/test_notify.lua index d1717fb..086b3bb 100644 --- a/tests/patch/test_notify.lua +++ b/tests/patch/test_notify.lua @@ -5,8 +5,8 @@ local original_notify local T = MiniTest.new_set({ hooks = { pre_case = function() - package.loaded["patch.notify"] = nil - notify = require("patch.notify") + package.loaded["patch.ui.notify"] = nil + notify = require("patch.ui.notify") original_notify = vim.notify end, post_case = function() diff --git a/tests/patch/test_prompt.lua b/tests/patch/test_prompt.lua index 25840fc..8df82f9 100644 --- a/tests/patch/test_prompt.lua +++ b/tests/patch/test_prompt.lua @@ -1,4 +1,4 @@ -local prompt = require("patch.prompt") +local prompt = require("patch.pi.prompt") local eq = MiniTest.expect.equality local T = MiniTest.new_set() diff --git a/tests/patch/test_replacement.lua b/tests/patch/test_replacement.lua index 2bfd334..8af86bb 100644 --- a/tests/patch/test_replacement.lua +++ b/tests/patch/test_replacement.lua @@ -1,6 +1,6 @@ local helpers = require("tests.helpers") -local replacement = require("patch.replacement") -local selection = require("patch.selection") +local replacement = require("patch.core.replacement") +local selection = require("patch.core.selection") local eq = MiniTest.expect.equality local buffer diff --git a/tests/patch/test_rpc.lua b/tests/patch/test_rpc.lua index 40e1dac..d1433c5 100644 --- a/tests/patch/test_rpc.lua +++ b/tests/patch/test_rpc.lua @@ -3,8 +3,8 @@ local rpc local original_system local function reset() - package.loaded["patch.rpc"] = nil - rpc = require("patch.rpc") + package.loaded["patch.pi.rpc"] = nil + rpc = require("patch.pi.rpc") original_system = vim.system end diff --git a/tests/patch/test_selection.lua b/tests/patch/test_selection.lua index 803ae04..3be119f 100644 --- a/tests/patch/test_selection.lua +++ b/tests/patch/test_selection.lua @@ -1,5 +1,5 @@ local helpers = require("tests.helpers") -local selection = require("patch.selection") +local selection = require("patch.core.selection") local eq = MiniTest.expect.equality local buffer diff --git a/tests/patch/test_workflow.lua b/tests/patch/test_workflow.lua new file mode 100644 index 0000000..a2a63ae --- /dev/null +++ b/tests/patch/test_workflow.lua @@ -0,0 +1,279 @@ +local eq = MiniTest.expect.equality +local patch +local mocks +local notifications +local input +local requests +local proposal +local location + +local function reset() + notifications = {} + requests = {} + proposal = { status = "pending" } + location = { source_buf = 1, extmark_id = 2 } + + mocks = { + prompt = { + build = function(_, instruction) + return "built:" .. instruction + end, + }, + selection = { + capture = function() + return location + end, + clear = function(value) + mocks.selection.cleared = value + end, + }, + context = { + capture = function(value) + eq(value, location) + return { before = {}, selected = { "old" }, after = {} } + end, + }, + client = { + setup = function(options) + mocks.client.setup_options = options + end, + request = function(message, on_complete) + local request = { message = message, on_complete = on_complete, state = "pending" } + table.insert(requests, request) + return request + end, + cancel = function(request) + request.cancelled = true + return true + end, + resolve_model = function(on_complete) + on_complete("provider/current", nil) + end, + get_available_models = function(on_complete) + on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil) + end, + select_model = function(model) + return model.provider .. "/" .. model.id + end, + }, + replacement = { + apply = function(value, response) + mocks.replacement.applied = { value, response } + proposal.location = value + return proposal + end, + accept = function(value) + mocks.replacement.accepted = value + return true + end, + reject = function(value) + mocks.replacement.rejected = value + return true + end, + begin_retry = function(value) + mocks.replacement.retry_started = value + value.status = "retrying" + return true + end, + complete_retry = function(value, response) + mocks.replacement.retry_completed = { value, response } + value.status = "pending" + return value + end, + abort_retry = function(value) + mocks.replacement.retry_aborted = value + value.status = "finished" + return true + end, + }, + ui = { + open_input = function(on_submit, on_close) + input = { submit = on_submit, close = on_close } + end, + open_menu = function(models, selected, on_submit) + mocks.ui.menu = { models = models, selected = selected, submit = on_submit } + end, + }, + notify = { + setup = function(provider) + mocks.notify.provider = provider + end, + send = function(message, level) + table.insert(notifications, { message = message, level = level }) + end, + }, + } + + package.loaded["patch.pi.prompt"] = mocks.prompt + package.loaded["patch.core.selection"] = mocks.selection + package.loaded["patch.core.context"] = mocks.context + package.loaded["patch.pi.client"] = mocks.client + package.loaded["patch.core.replacement"] = mocks.replacement + package.loaded["patch.ui"] = mocks.ui + package.loaded["patch.ui.notify"] = mocks.notify + package.loaded["patch.core.generation"] = nil + package.loaded["patch.core.workflow"] = nil + patch = require("patch.core.workflow") +end + +local function cleanup() + for _, name in ipairs({ + "patch.core.workflow", "patch.core.generation", "patch.pi.prompt", + "patch.core.selection", "patch.core.context", "patch.pi.client", + "patch.core.replacement", "patch.ui", "patch.ui.notify", + }) do + package.loaded[name] = nil + end +end + +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) + +local function test(name, action) + T[name] = action +end + +local function notification(message, level) + for _, item in ipairs(notifications) do + if item.message == message and (not level or item.level == level) then + return item + end + end +end + +local function start_and_submit() + patch.start() + eq(type(input), "table") + input.submit("change it") + eq(#requests, 1) +end + +local function complete_generation(response, err) + requests[#requests].on_complete(response, err) +end + +test("runs capture, generation, review, and acceptance", function() + start_and_submit() + + eq(requests[1].message, "built:change it") + assert(notification("patch: generating...", vim.log.levels.INFO)) + + complete_generation("replacement", nil) + eq(mocks.replacement.applied, { location, "replacement" }) + assert(notification("patch: complete", vim.log.levels.INFO)) + + patch.accept() + eq(mocks.replacement.accepted, proposal) + + patch.accept() + assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) +end) + +test("rejects a reviewed proposal", function() + start_and_submit() + complete_generation("replacement", nil) + + patch.reject() + + eq(mocks.replacement.rejected, proposal) + patch.reject() + assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) +end) + +test("retries a proposal and applies the new response", function() + start_and_submit() + complete_generation("first", nil) + + patch.retry() + eq(mocks.replacement.retry_started, proposal) + eq(#requests, 2) + + complete_generation("second", nil) + eq(mocks.replacement.retry_completed, { proposal, "second" }) + assert(notification("patch: complete", vim.log.levels.INFO)) +end) + +test("aborts a failed retry and reports the error", function() + start_and_submit() + complete_generation("first", nil) + patch.retry() + + complete_generation(nil, "request failed") + + eq(mocks.replacement.retry_aborted, proposal) + assert(notification("patch: request failed", vim.log.levels.ERROR)) +end) + +test("cancels an active request and suppresses the cancellation error", function() + start_and_submit() + + patch.cancel() + eq(requests[1].cancelled, true) + assert(notification("patch: cancelled", vim.log.levels.INFO)) + + complete_generation(nil, "cancelled") + eq(mocks.selection.cleared, location) + eq(notification("patch: cancelled", vim.log.levels.ERROR), nil) +end) + +test("cleans up when input closes or generation fails", function() + patch.start() + input.close() + eq(mocks.selection.cleared, location) + + mocks.selection.cleared = nil + start_and_submit() + complete_generation(nil, "failed") + eq(mocks.selection.cleared, location) + assert(notification("patch: failed", vim.log.levels.ERROR)) +end) + +test("blocks overlapping workflows", function() + patch.start() + patch.start() + assert(notification("patch: submit or close the active instruction first", vim.log.levels.WARN)) + + input.submit("change") + patch.start() + assert(notification("patch: a replacement is already being generated", vim.log.levels.WARN)) + + complete_generation("replacement", nil) + patch.start() + assert(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN)) +end) + +test("ignores stale completion callbacks", function() + start_and_submit() + local stale = requests[1].on_complete + patch.cancel() + stale(nil, "cancelled") + + patch.start() + input.submit("new change") + stale("stale response", nil) + + eq(mocks.replacement.applied, nil) + eq(#requests, 2) +end) + +test("opens the model menu and records a selection", function() + patch.open_menu() + + eq(mocks.ui.menu.selected, "provider/current") + eq(mocks.ui.menu.models[1].id, "model") + mocks.ui.menu.submit(mocks.ui.menu.models[1]) + + assert(notification("patch: using provider/model", vim.log.levels.INFO)) +end) + +test("warns when actions have no active workflow", function() + patch.accept() + patch.reject() + patch.retry() + patch.cancel() + + assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) + assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) + assert(notification("patch: no active proposal to retry", vim.log.levels.WARN)) + assert(notification("patch: nothing to cancel", vim.log.levels.WARN)) +end) +return T