@@ -303,6 +303,25 @@ T["ACP Responses"]["handles partial JSON messages correctly"] = function()
303303 h .eq (result .third_message , ' {"jsonrpc":"2.0","id":3,"result":{}}' )
304304end
305305
306+ T [" ACP Responses" ][" handles CRLF line endings in stdout" ] = function ()
307+ local result = child .lua ([[
308+ local connection = ACP.new({
309+ adapter = test_adapter,
310+ opts = { schedule_wrap = function(fn) return fn end }
311+ })
312+ local processed = {}
313+ function connection:_process_json_message(line)
314+ table.insert(processed, line)
315+ end
316+ connection:_process_output('{"jsonrpc":"2.0","id":10,"result":{}}\r\n{"jsonrpc":"2.0","id":11,"result":{}}\n')
317+ return processed
318+ ]] )
319+
320+ h .eq (# result , 2 )
321+ h .eq (result [1 ], ' {"jsonrpc":"2.0","id":10,"result":{}}' )
322+ h .eq (result [2 ], ' {"jsonrpc":"2.0","id":11,"result":{}}' )
323+ end
324+
306325T [" ACP Responses" ][" processes real streaming prompt responses" ] = function ()
307326 local result = child .lua ([[
308327 local connection = ACP.new({
@@ -341,7 +360,90 @@ T["ACP Responses"]["processes real streaming prompt responses"] = function()
341360 h .eq (result .message_chunks [1 ].content .text , " Expressive, elegant." )
342361end
343362
344- T [" ACP Responses" ][" handles fs/read_text_file and returns content" ] = function ()
363+ T [" ACP Responses" ][" dispatches session/request_permission to active prompt" ] = function ()
364+ local result = child .lua ([[
365+ local connection = ACP.new({
366+ adapter = test_adapter,
367+ opts = { schedule_wrap = function(fn) return fn end }
368+ })
369+ connection.session_id = "sess-123"
370+ local captured = {}
371+ connection._active_prompt = {
372+ _handle_permission_request = function(_, id, params)
373+ captured.id = id
374+ captured.sid = params.sessionId
375+ end
376+ }
377+ local req = vim.json.encode({
378+ jsonrpc = "2.0",
379+ id = 99,
380+ method = "session/request_permission",
381+ params = { sessionId = "sess-123", options = {}, toolCall = { toolCallId = "tc1" } }
382+ })
383+ connection:_process_output(req .. "\r\n")
384+ return captured
385+ ]] )
386+ h .eq (result .id , 99 )
387+ h .eq (result .sid , " sess-123" )
388+ end
389+
390+ T [" ACP Responses" ][" _handle_done when stopReason present" ] = function ()
391+ local result = child .lua ([[
392+ local connection = ACP.new({
393+ adapter = test_adapter,
394+ opts = { schedule_wrap = function(fn) return fn end }
395+ })
396+ local seen
397+ connection._active_prompt = {
398+ _handle_done = function(_, sr) seen = sr end
399+ }
400+ connection:_process_json_message('{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}')
401+ return seen
402+ ]] )
403+ h .eq (result , " end_turn" )
404+ end
405+
406+ T [" ACP Connection" ][" _handle_exit resets state, calls on_exit and prompts done(canceled)" ] = function ()
407+ local result = child .lua ([[
408+ local connection = ACP.new({
409+ adapter = test_adapter,
410+ opts = { schedule_wrap = function(fn) return fn end }
411+ })
412+ connection.adapter_modified = {
413+ handlers = {
414+ on_exit = function(_, code) _G.__on_exit_code = code end
415+ }
416+ }
417+ local seen_done
418+ connection._active_prompt = {
419+ _handle_done = function(_, sr) seen_done = sr end
420+ }
421+ connection._initialized = true
422+ connection._authenticated = true
423+ connection.session_id = "sid"
424+ connection.pending_responses = { x = 1 }
425+
426+ connection:_handle_exit(123, 0)
427+
428+ return {
429+ init = connection._initialized,
430+ authed = connection._authenticated,
431+ sid = connection.session_id,
432+ pending = vim.tbl_count(connection.pending_responses),
433+ on_exit_code = _G.__on_exit_code,
434+ done_reason = seen_done,
435+ }
436+ ]] )
437+
438+ h .eq (result .init , false )
439+ h .eq (result .authed , false )
440+ h .eq (result .sid , nil )
441+ h .eq (result .pending , 0 )
442+ h .eq (result .on_exit_code , 123 )
443+ h .eq (result .done_reason , " canceled" )
444+ end
445+
446+ T [" ACP Responses" ][" fs/read_text_file and returns content" ] = function ()
345447 local result = child .lua ([[
346448 -- Create a temp file
347449 local tmp = vim.fn.tempname()
@@ -376,7 +478,60 @@ T["ACP Responses"]["handles fs/read_text_file and returns content"] = function()
376478 h .eq (true , result .result .content :find (" line1" ) ~= nil )
377479end
378480
379- T [" ACP Responses" ][" handles fs/write_text_file and responds with null" ] = function ()
481+ T [" ACP Responses" ][" fs/read_text_file ENOENT returns empty content" ] = function ()
482+ local result = child .lua ([[
483+ package.loaded["codecompanion.strategies.chat.acp.fs"] = {
484+ read_text_file = function(path) return false, "ENOENT: " .. path end
485+ }
486+ local connection = ACP.new({
487+ adapter = test_adapter,
488+ opts = { schedule_wrap = function(fn) return fn end }
489+ })
490+ connection.session_id = "s1"
491+ local sent = {}
492+ function connection:_write_to_process(data)
493+ table.insert(sent, vim.trim(data))
494+ return true
495+ end
496+ local req = vim.json.encode({
497+ jsonrpc = "2.0", id = 5, method = "fs/read_text_file",
498+ params = { sessionId = "s1", path = "/tmp/missing.txt" }
499+ })
500+ connection:_process_output(req .. "\n")
501+ return vim.json.decode(sent[#sent])
502+ ]] )
503+ h .eq (result .id , 5 )
504+ h .eq (result .result .content , " " )
505+ end
506+
507+ T [" ACP Responses" ][" fs/read_text_file rejects invalid sessionId" ] = function ()
508+ local result = child .lua ([[
509+ package.loaded["codecompanion.strategies.chat.acp.fs"] = {
510+ read_text_file = function(path) return true, "should_not_be_called" end
511+ }
512+ local connection = ACP.new({
513+ adapter = test_adapter,
514+ opts = { schedule_wrap = function(fn) return fn end }
515+ })
516+ connection.session_id = "correct-session"
517+ local sent = {}
518+ function connection:_write_to_process(data)
519+ table.insert(sent, vim.trim(data))
520+ return true
521+ end
522+ local req = vim.json.encode({
523+ jsonrpc = "2.0", id = 6, method = "fs/read_text_file",
524+ params = { sessionId = "wrong-session", path = "/tmp/x" }
525+ })
526+ connection:_process_output(req .. "\n")
527+ return vim.json.decode(sent[#sent])
528+ ]] )
529+
530+ h .eq (result .id , 6 )
531+ h .eq (result .error .code , - 32602 )
532+ end
533+
534+ T [" ACP Responses" ][" fs/write_text_file and responds with null" ] = function ()
380535 local result = child .lua ([[
381536 -- Stub the fs module to capture writes
382537 local writes = {}
@@ -466,11 +621,40 @@ T["ACP Responses"]["fs/write_text_file rejects invalid sessionId"] = function()
466621
467622 h .eq (# result , 1 )
468623 h .eq (result [1 ].id , 7 )
624+
469625 -- JSON-RPC error response expected
470626 h .eq (type (result [1 ].error ), " table" )
471627 h .eq (result [1 ].error .code , - 32602 )
472628end
473629
630+ T [" ACP Responses" ][" fs/write_text_file failure returns JSON-RPC error" ] = function ()
631+ local result = child .lua ([[
632+ package.loaded["codecompanion.strategies.chat.acp.fs"] = {
633+ write_text_file = function(_) return nil, "EACCES" end
634+ }
635+ local connection = ACP.new({
636+ adapter = test_adapter,
637+ opts = { schedule_wrap = function(fn) return fn end }
638+ })
639+ connection.session_id = "s1"
640+ local sent = {}
641+ function connection:_write_to_process(data)
642+ table.insert(sent, vim.trim(data))
643+ return true
644+ end
645+ local req = vim.json.encode({
646+ jsonrpc = "2.0", id = 8, method = "fs/write_text_file",
647+ params = { sessionId = "s1", path = "/root/forbidden", content = "x" }
648+ })
649+ connection:_process_output(req .. "\n")
650+ return vim.json.decode(sent[#sent])
651+ ]] )
652+
653+ h .eq (result .id , 8 )
654+ h .eq (type (result .error ), " table" )
655+ h .is_true (result .error .message :find (" fs/write_text_file failed" ) ~= nil )
656+ end
657+
474658T [" ACP Responses" ][" ignores notifications for other sessions" ] = function ()
475659 local result = child .lua ([[
476660 local connection = ACP.new({
0 commit comments