Skip to content

Commit ce1ecb2

Browse files
authored
feat: remove fs events so agents are free to read and write files (#141)
- This was motivated by #131 - Claude is not sending the events anymore - This also reduces the Agentic responsibilities, leaving it to the agents
1 parent 332b971 commit ce1ecb2

8 files changed

Lines changed: 501 additions & 88 deletions

File tree

lua/agentic/acp/acp_client.lua

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
local Logger = require("agentic.utils.logger")
22
local transport_module = require("agentic.acp.acp_transport")
3-
local FileSystem = require("agentic.utils.file_system")
43

54
--[[
65
CRITICAL: Type annotations in this file are essential for Lua Language Server support.
@@ -20,6 +19,7 @@ local KNOWN_ACP_KINDS = {
2019
fetch = true,
2120
other = true,
2221
create = true,
22+
write = true,
2323
switch_mode = true,
2424
}
2525

@@ -70,8 +70,8 @@ function ACPClient:new(config, on_ready)
7070
},
7171
capabilities = {
7272
fs = {
73-
readTextFile = true,
74-
writeTextFile = true,
73+
readTextFile = false,
74+
writeTextFile = false,
7575
},
7676
terminal = false,
7777
},
@@ -288,10 +288,10 @@ function ACPClient:_handle_notification(message_id, method, params)
288288
elseif method == "session/request_permission" then
289289
--- @diagnostic disable-next-line: param-type-mismatch
290290
self:__handle_request_permission(message_id, params)
291-
elseif method == "fs/read_text_file" then
292-
self:_handle_read_text_file(message_id, params)
293-
elseif method == "fs/write_text_file" then
294-
self:_handle_write_text_file(message_id, params)
291+
elseif method == "fs/read_text_file" or method == "fs/write_text_file" then
292+
Logger.debug(
293+
string.format("Received '%s' notification, ignoring it", method)
294+
)
295295
else
296296
Logger.notify("Unknown notification method: " .. method)
297297
end
@@ -460,50 +460,6 @@ function ACPClient:__handle_request_permission(message_id, request)
460460
end)
461461
end
462462

463-
--- @param message_id number
464-
--- @param params table
465-
function ACPClient:_handle_read_text_file(message_id, params)
466-
local session_id = params.sessionId
467-
local path = params.path
468-
469-
if not session_id or not path then
470-
Logger.notify("Received fs/read_text_file without sessionId or path")
471-
return
472-
end
473-
474-
self:__with_subscriber(session_id, function()
475-
FileSystem.read_file(
476-
path,
477-
params.line ~= vim.NIL and params.line or nil,
478-
params.limit ~= vim.NIL and params.limit or nil,
479-
function(content)
480-
self:__send_result(message_id, { content = content })
481-
end
482-
)
483-
end)
484-
end
485-
486-
--- @param message_id number
487-
--- @param params table
488-
function ACPClient:_handle_write_text_file(message_id, params)
489-
local session_id = params.sessionId
490-
local path = params.path
491-
local content = params.content
492-
493-
if not session_id or not path or not content then
494-
Logger.notify(
495-
"Received fs/write_text_file without sessionId, path, or content"
496-
)
497-
return
498-
end
499-
500-
self:__with_subscriber(session_id, function()
501-
FileSystem.write_file(path, content, function(error)
502-
self:__send_result(message_id, error == nil and vim.NIL or error)
503-
end)
504-
end)
505-
end
506-
507463
function ACPClient:stop()
508464
self.transport:stop()
509465
end
@@ -760,6 +716,7 @@ return ACPClient
760716
--- | "SubAgent"
761717
--- | "other"
762718
--- | "create"
719+
--- | "write"
763720
--- | "Skill"
764721
--- | "switch_mode"
765722

lua/agentic/init.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,17 @@ function Agentic.setup(opts)
225225

226226
Theme.setup()
227227

228+
-- Force-reload buffers when files change on disk (e.g., agent edits files directly).
229+
-- Suppresses the "file changed" prompt so modified buffers reload silently,
230+
-- matching Cursor/Zed behavior where agent changes always win.
231+
vim.api.nvim_create_autocmd("FileChangedShell", {
232+
group = cleanup_group,
233+
pattern = "*",
234+
callback = function()
235+
vim.v.fcs_choice = "reload"
236+
end,
237+
})
238+
228239
vim.api.nvim_create_autocmd("VimLeavePre", {
229240
group = cleanup_group,
230241
callback = function()

lua/agentic/session_manager.lua

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ local WidgetLayout = require("agentic.ui.widget_layout")
2020
--- @class agentic._SessionManagerPrivate
2121
local P = {}
2222

23+
--- Tool call kinds that mutate files on disk.
24+
--- When these complete, buffers must be reloaded via checktime.
25+
local FILE_MUTATING_KINDS = {
26+
edit = true,
27+
create = true,
28+
write = true,
29+
delete = true,
30+
move = true,
31+
}
32+
2333
--- Safely invoke a user-configured hook
2434
--- @param hook_name "on_prompt_submit" | "on_response_complete"
2535
--- @param data table
@@ -237,6 +247,51 @@ function SessionManager:_on_session_update(update)
237247
end
238248
end
239249

250+
--- Handle tool call update: update UI, history, diff preview, permissions, and reload buffers
251+
--- @param tool_call_update agentic.ui.MessageWriter.ToolCallBase
252+
function SessionManager:_on_tool_call_update(tool_call_update)
253+
self.message_writer:update_tool_call_block(tool_call_update)
254+
255+
--- @type agentic.ui.ChatHistory.ToolCall
256+
local tool_call = {
257+
type = "tool_call",
258+
tool_call_id = tool_call_update.tool_call_id,
259+
status = tool_call_update.status,
260+
body = tool_call_update.body,
261+
diff = tool_call_update.diff,
262+
}
263+
264+
self.chat_history:update_tool_call(tool_call_update.tool_call_id, tool_call)
265+
266+
-- pre-emptively clear diff preview when tool call update is received, as it's either done or failed
267+
local is_rejection = tool_call_update.status == "failed"
268+
self:_clear_diff_in_buffer(tool_call_update.tool_call_id, is_rejection)
269+
270+
-- Remove the permission request if the tool call failed before user granted it
271+
if tool_call_update.status == "failed" then
272+
self.permission_manager:remove_request_by_tool_call_id(
273+
tool_call_update.tool_call_id
274+
)
275+
end
276+
277+
-- Reload buffers when file-mutating tool calls complete
278+
if tool_call_update.status == "completed" then
279+
local tracker =
280+
self.message_writer.tool_call_blocks[tool_call_update.tool_call_id]
281+
282+
if tracker and tracker.kind and FILE_MUTATING_KINDS[tracker.kind] then
283+
vim.cmd.checktime()
284+
end
285+
end
286+
287+
if
288+
not self.permission_manager.current_request
289+
and #self.permission_manager.queue == 0
290+
then
291+
self.status_animation:start("generating")
292+
end
293+
end
294+
240295
--- Send the newly selected mode to the agent and handle the response
241296
--- @param mode_id string
242297
function SessionManager:_handle_mode_change(mode_id)
@@ -549,43 +604,7 @@ function SessionManager:new_session(opts)
549604
end,
550605

551606
on_tool_call_update = function(tool_call_update)
552-
self.message_writer:update_tool_call_block(tool_call_update)
553-
--- @type agentic.ui.ChatHistory.ToolCall
554-
local tool_call = {
555-
type = "tool_call",
556-
tool_call_id = tool_call_update.tool_call_id,
557-
status = tool_call_update.status,
558-
body = tool_call_update.body,
559-
diff = tool_call_update.diff,
560-
}
561-
562-
self.chat_history:update_tool_call(
563-
tool_call_update.tool_call_id,
564-
tool_call
565-
)
566-
567-
-- pre-emptively clear diff preview when tool call update is received, as it's either done or failed
568-
local is_rejection = tool_call_update.status == "failed"
569-
self:_clear_diff_in_buffer(
570-
tool_call_update.tool_call_id,
571-
is_rejection
572-
)
573-
574-
-- I need to remove the permission request if the tool call failed before user granted it
575-
-- It could happen for many reasons, like invalid parameters, tool not found, etc.
576-
-- Mostly comes from the Agent.
577-
if tool_call_update.status == "failed" then
578-
self.permission_manager:remove_request_by_tool_call_id(
579-
tool_call_update.tool_call_id
580-
)
581-
end
582-
583-
if
584-
not self.permission_manager.current_request
585-
and #self.permission_manager.queue == 0
586-
then
587-
self.status_animation:start("generating")
588-
end
607+
self:_on_tool_call_update(tool_call_update)
589608
end,
590609

591610
on_request_permission = function(request, callback)

lua/agentic/session_manager.test.lua

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,128 @@ describe("agentic.SessionManager", function()
340340
assert.spy(session.new_session).was.called(1)
341341
end)
342342
end)
343+
344+
describe("FileChangedShell autocommand", function()
345+
local Child = require("tests.helpers.child")
346+
local child = Child:new()
347+
348+
before_each(function()
349+
child.setup()
350+
end)
351+
352+
after_each(function()
353+
child.stop()
354+
end)
355+
356+
it("sets fcs_choice to reload when FileChangedShell fires", function()
357+
child.v.fcs_choice = ""
358+
child.api.nvim_exec_autocmds("FileChangedShell", {
359+
group = "AgenticCleanup",
360+
pattern = "*",
361+
})
362+
363+
assert.equal("reload", child.v.fcs_choice)
364+
end)
365+
end)
366+
367+
describe("on_tool_call_update: buffer reload", function()
368+
--- @type TestStub
369+
local checktime_stub
370+
--- @type TestStub
371+
local schedule_stub
372+
373+
--- @param tool_call_blocks table<string, table>
374+
--- @return agentic.SessionManager
375+
local function make_session(tool_call_blocks)
376+
return {
377+
message_writer = {
378+
update_tool_call_block = function() end,
379+
tool_call_blocks = tool_call_blocks,
380+
},
381+
permission_manager = {
382+
current_request = nil,
383+
queue = {},
384+
remove_request_by_tool_call_id = function() end,
385+
},
386+
status_animation = { start = function() end },
387+
_clear_diff_in_buffer = function() end,
388+
chat_history = { update_tool_call = function() end },
389+
} --[[@as agentic.SessionManager]]
390+
end
391+
392+
before_each(function()
393+
checktime_stub = spy.stub(vim.cmd, "checktime")
394+
schedule_stub = spy.stub(vim, "schedule")
395+
schedule_stub:invokes(function(fn)
396+
fn()
397+
end)
398+
end)
399+
400+
after_each(function()
401+
checktime_stub:revert()
402+
schedule_stub:revert()
403+
end)
404+
405+
it("calls checktime for each file-mutating kind", function()
406+
for _, kind in ipairs({
407+
"edit",
408+
"create",
409+
"write",
410+
"delete",
411+
"move",
412+
}) do
413+
checktime_stub:reset()
414+
local tc_id = "tc-" .. kind
415+
local session = make_session({
416+
[tc_id] = { kind = kind, status = "in_progress" },
417+
})
418+
419+
SessionManager._on_tool_call_update(
420+
session,
421+
{ tool_call_id = tc_id, status = "completed" }
422+
)
423+
424+
assert.spy(checktime_stub).was.called(1)
425+
end
426+
end)
427+
428+
it("does not call checktime for failed tool calls", function()
429+
local session = make_session({
430+
["tc-1"] = { kind = "edit", status = "in_progress" },
431+
})
432+
433+
SessionManager._on_tool_call_update(
434+
session,
435+
{ tool_call_id = "tc-1", status = "failed" }
436+
)
437+
438+
assert.spy(checktime_stub).was.called(0)
439+
end)
440+
441+
it("does not call checktime for non-mutating kinds", function()
442+
local session = make_session({
443+
["tc-1"] = { kind = "read", status = "in_progress" },
444+
})
445+
446+
SessionManager._on_tool_call_update(
447+
session,
448+
{ tool_call_id = "tc-1", status = "completed" }
449+
)
450+
451+
assert.spy(checktime_stub).was.called(0)
452+
end)
453+
454+
it("does not call checktime when tracker is missing", function()
455+
local debug_stub = spy.stub(Logger, "debug")
456+
local session = make_session({})
457+
458+
SessionManager._on_tool_call_update(
459+
session,
460+
{ tool_call_id = "tc-missing", status = "completed" }
461+
)
462+
463+
assert.spy(checktime_stub).was.called(0)
464+
debug_stub:revert()
465+
end)
466+
end)
343467
end)

0 commit comments

Comments
 (0)