---@diagnostic disable: duplicate-set-field, need-check-nil, param-type-mismatch, assign-type-mismatch local rpc = require 'bugabinga.pivi.rpc' local fake = dofile 'tests/helpers/pivi_fake.lua' local start = function( channel, options ) options = options or {} options.command = { 'pi', '--mode', 'rpc', } options.spawn = channel.spawn return rpc.start( options ) end describe( 'pivi rpc', function() it( 'spawns with the given command and working directory', function() local channel = fake.new() start( channel, { cwd = '/tmp', } ) assert.same( { 'pi', '--mode', 'rpc', }, channel.command ) assert.equal( '/tmp', channel.options.cwd ) end ) it( 'writes commands as single line JSONL records', function() local channel = fake.new() local client = start( channel ) client.notify { type = 'abort', } assert.equal( '{"type":"abort"}\n', channel.writes[1] ) end ) it( 'correlates a response with its request id', function() local channel = fake.new() local client = start( channel ) local answer local id = client.request( { type = 'get_state', }, function( response ) answer = response end ) channel.frame { id = 'other', type = 'response', command = 'get_state', success = true, } channel.frame { id = id, type = 'response', command = 'get_state', success = true, data = { messageCount = 3, }, } assert.is_true( channel.settle( function() return answer ~= nil end ) ) assert.equal( id, answer.id ) assert.equal( 3, answer.data.messageCount ) end ) it( 'never delivers a frame from a fast event context', function() local channel = fake.new() local contexts = {} start( channel, { on_event = function() table.insert( contexts, vim.in_fast_event() ) end, } ) channel.frame { type = 'agent_start', } assert.is_true( channel.settle( function() return #contexts == 1 end ) ) assert.is_false( contexts[1] ) end ) it( 'delivers interleaved events and responses in arrival order', function() local channel = fake.new() local seen = {} local client = start( channel, { on_event = function( event ) table.insert( seen, event.type ) end, } ) client.request( { type = 'prompt', message = 'hi', }, function() table.insert( seen, 'response' ) end ) local id = channel.commands[1].id channel.emit( vim.json.encode { id = id, type = 'response', command = 'prompt', success = true, } .. '\n' .. vim.json.encode { type = 'agent_start', } .. '\n' .. vim.json.encode { type = 'agent_settled', } .. '\n' ) assert.is_true( channel.settle( function() return #seen == 3 end ) ) assert.same( { 'response', 'agent_start', 'agent_settled', }, seen ) end ) it( 'reassembles frames split at arbitrary byte boundaries', function() local channel = fake.new() local events = {} start( channel, { on_event = function( event ) table.insert( events, event ) end, } ) local payload = vim.json.encode { type = 'message_update', assistantMessageEvent = { type = 'text_delta', delta = 'hello', }, } .. '\n' for index = 1, #payload do channel.emit( payload:sub( index, index ) ) end assert.is_true( channel.settle( function() return #events == 1 end ) ) assert.equal( 'hello', events[1].assistantMessageEvent.delta ) end ) it( 'reports an undecodable record instead of crashing', function() local channel = fake.new() local failures = {} start( channel, { on_decode_error = function( record ) table.insert( failures, record ) end, } ) channel.emit 'this is not json\n' assert.is_true( channel.settle( function() return #failures == 1 end ) ) assert.equal( 'this is not json', failures[1] ) end ) it( 'keeps standard error separate from protocol output', function() local channel = fake.new() local events, noise = {}, {} start( channel, { on_event = function( event ) table.insert( events, event ) end, on_stderr = function( data ) table.insert( noise, data ) end, } ) channel.stderr_emit 'warning: something\n' assert.is_true( channel.settle( function() return #noise == 1 end ) ) assert.equal( 0, #events ) end ) it( 'flushes a trailing record and fails pending requests when the child exits', function() local channel = fake.new() local events, answers, exits = {}, {}, {} local client = start( channel, { on_event = function( event ) table.insert( events, event ) end, on_exit = function( code ) table.insert( exits, code ) end, } ) client.request( { type = 'get_state', }, function( response ) table.insert( answers, response ) end ) channel.emit( vim.json.encode { type = 'agent_settled', } ) channel.close( 2 ) assert.is_true( channel.settle( function() return #exits == 1 end ) ) assert.equal( 2, exits[1] ) assert.equal( 1, #events ) assert.equal( 1, #answers ) assert.is_false( answers[1].success ) assert.is_false( client.is_running() ) end ) it( 'rejects a request sent after the child exited', function() local channel = fake.new() local client = start( channel ) channel.close( 0 ) assert.is_true( channel.settle( function() return not client.is_running() end ) ) local answer client.request( { type = 'prompt', message = 'late', }, function( response ) answer = response end ) assert.is_false( answer.success ) assert.is_string( answer.error ) end ) it( 'reports an unspawnable executable instead of raising', function() local exited = false local client = rpc.start { command = { 'definitely-not-a-real-program', }, spawn = function() error 'ENOENT: no such file or directory' end, -- A process that never started reports no exit code. on_exit = function( code ) exited = true assert.is_nil( code ) end, } assert.is_false( client.is_running() ) assert.is_true( client.failure():find( 'ENOENT', 1, true ) ~= nil ) local answer client.request( { type = 'prompt', message = 'hi', }, function( response ) answer = response end ) assert.is_false( answer.success ) -- Stopping a process that never started must be harmless. client.stop() assert.is_true( vim.wait( 2000, function() return exited end, 5 ) ) end ) it( 'reports a handler that throws instead of swallowing it', function() local channel = fake.new() local reported = {} start( channel, { on_event = function() error 'handler blew up' end, on_failure = function( failure ) table.insert( reported, failure ) end, } ) channel.frame { type = 'agent_start', } assert.is_true( channel.settle( function() return #reported == 1 end ) ) assert.is_true( reported[1]:find( 'handler blew up', 1, true ) ~= nil ) end ) it( 'closes stdin first so the child can exit on its own', function() local channel = fake.new() local client = start( channel ) client.stop() assert.is_true( channel.stdin_closed ) -- Killing straight away would orphan the runtime behind a launcher shim. assert.is_false( channel.killed ) channel.close( 0 ) assert.is_true( channel.settle( function() return not client.is_running() end ) ) vim.wait( 2500, function() return channel.killed end, 20 ) assert.is_false( channel.killed ) end ) it( 'terminates a child that ignores a closed input', function() local channel = fake.new() local client = start( channel ) client.stop() assert.is_true( vim.wait( 5000, function() return channel.killed end, 20 ) ) end ) end )