Skip to content

Commit a3e114d

Browse files
committed
feat(api): support /global/event streaming
Add normalization for /global/event envelopes (normalize_global_event) to convert the new global event shape and sync events into the legacy event This should fix #374
1 parent cef831d commit a3e114d

3 files changed

Lines changed: 204 additions & 5 deletions

File tree

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
⚠️ There is a blocking issue with the opencode CLI versions `1.14.42` and up that prevent opencode.nvim from rendering streaming events. Please downgrade to `1.14.41` or wait for a fix. Follow the issue here:
2-
3-
https://github.com/anomalyco/opencode/issues/26697
4-
51
# 🤖 opencode.nvim
62

73
> neovim frontend for opencode - a terminal-based AI coding agent

lua/opencode/api_client.lua

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local url_encode = require('opencode.util').url_encode
44
local apply_path_map = require('opencode.util').apply_path_map
55
local reverse_transform_paths_recursive = require('opencode.util').reverse_transform_paths_recursive
66
local transform_paths_recursive = require('opencode.util').transform_paths_recursive
7+
local is_version_greater_or_equal = require('opencode.util').is_version_greater_or_equal
78

89
--- @class OpencodeApiClient
910
--- @field base_url string The base URL of the opencode server
@@ -19,6 +20,51 @@ function OpencodeApiClient.new(base_url)
1920
}, OpencodeApiClient)
2021
end
2122

23+
---Convert /global/event envelopes into the legacy event shape consumed by the
24+
---rest of the plugin.
25+
---@param event table|nil
26+
---@return table|nil
27+
local function normalize_global_event(event)
28+
if type(event) ~= 'table' then
29+
return nil
30+
end
31+
32+
local payload = event.payload
33+
if type(payload) ~= 'table' then
34+
return nil
35+
end
36+
37+
if payload.type == 'sync' then
38+
local sync_event = payload.syncEvent
39+
if type(sync_event) ~= 'table' then
40+
return nil
41+
end
42+
43+
local event_type = sync_event.type
44+
if type(event_type) ~= 'string' then
45+
return nil
46+
end
47+
48+
event_type = event_type:gsub('%.%d+$', '')
49+
50+
return {
51+
id = sync_event.id or payload.id,
52+
type = event_type,
53+
properties = sync_event.data,
54+
}
55+
end
56+
57+
if type(payload.type) ~= 'string' then
58+
return nil
59+
end
60+
61+
return {
62+
id = payload.id,
63+
type = payload.type,
64+
properties = payload.properties,
65+
}
66+
end
67+
2268
---Ensure that base_url is set. Even thought we're subscribed to
2369
---state.opencode_server, we still need this check because
2470
---it's possible someone will try to make an api call in their event
@@ -447,7 +493,17 @@ end
447493
--- @param on_event fun(event: table) Event callback
448494
--- @return table The streaming job handle
449495
function OpencodeApiClient:subscribe_to_events(directory, on_event)
450-
self:_ensure_base_url()
496+
-- Make sure we have a base URL before attempting to subscribe. If we
497+
-- cannot determine a base URL (server not running), return nil so
498+
-- callers can handle the absence of a subscription without an error.
499+
if not self:_ensure_base_url() then
500+
return nil
501+
end
502+
503+
if is_version_greater_or_equal(state.opencode_cli_version, '1.14.42') then
504+
return self:_subscribe_to_global_events(directory, on_event)
505+
end
506+
451507
local url = self.base_url .. '/event'
452508
if directory then
453509
local mapped_directory = apply_path_map(directory)
@@ -464,6 +520,39 @@ function OpencodeApiClient:subscribe_to_events(directory, on_event)
464520
end)
465521
end
466522

523+
--- Subscribe to events (streaming)
524+
--- @param directory string|nil Directory path
525+
--- @param on_event fun(event: table) Event callback
526+
--- @return table The streaming job handle
527+
function OpencodeApiClient:_subscribe_to_global_events(directory, on_event)
528+
-- Ensure base_url is available. If not, return nil instead of erroring.
529+
if not self:_ensure_base_url() then
530+
return nil
531+
end
532+
533+
if not is_version_greater_or_equal(state.opencode_cli_version, '1.14.42') then
534+
error('subscribe_to_global_events should not be called directly')
535+
end
536+
537+
local url = self.base_url .. '/global/event'
538+
if directory then
539+
local mapped_directory = apply_path_map(directory)
540+
url = url .. '?directory=' .. url_encode(mapped_directory)
541+
end
542+
543+
return server_job.stream_api(url, 'GET', nil, function(chunk)
544+
chunk = chunk:gsub('^data:%s*', '')
545+
local ok, event = pcall(vim.json.decode, vim.trim(chunk))
546+
if ok and event then
547+
local normalized_event = normalize_global_event(event)
548+
if normalized_event then
549+
local transformed_event = reverse_transform_paths_recursive(normalized_event)
550+
on_event(transformed_event --[[@as table]])
551+
end
552+
end
553+
end)
554+
end
555+
467556
-- Tool endpoints
468557

469558
--- List all tool IDs (including built-in and dynamically registered)

tests/unit/api_client_spec.lua

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ local api_client = require('opencode.api_client')
22
local assert = require('luassert')
33

44
describe('api_client', function()
5+
local original_cli_version
6+
local state
7+
8+
before_each(function()
9+
state = require('opencode.state')
10+
original_cli_version = state.opencode_cli_version
11+
end)
12+
13+
after_each(function()
14+
state.jobs.set_opencode_cli_version(original_cli_version)
15+
end)
16+
517
it('should create a new client instance', function()
618
local client = api_client.new('http://localhost:8080')
719
assert.is_not_nil(client)
@@ -104,4 +116,106 @@ describe('api_client', function()
104116
server_job.call_api = original_call_api
105117
vim.fn.getcwd = original_cwd
106118
end)
119+
120+
it('normalizes /global/event payloads into legacy event shape', function()
121+
local server_job = require('opencode.server_job')
122+
local original_stream_api = server_job.stream_api
123+
state.jobs.set_opencode_cli_version('1.14.42')
124+
125+
local received = {}
126+
127+
server_job.stream_api = function(_, _, _, on_chunk)
128+
on_chunk(
129+
'data: ' .. vim.json.encode({
130+
payload = {
131+
id = 'evt_1',
132+
type = 'session.status',
133+
properties = {
134+
sessionID = 'ses_1',
135+
status = { type = 'busy' },
136+
},
137+
},
138+
})
139+
)
140+
141+
return { shutdown = function() end }
142+
end
143+
144+
local client = api_client.new('http://localhost:8080')
145+
client:subscribe_to_events('/some/directory', function(event)
146+
table.insert(received, event)
147+
end)
148+
149+
assert.same({
150+
{
151+
id = 'evt_1',
152+
type = 'session.status',
153+
properties = {
154+
sessionID = 'ses_1',
155+
status = { type = 'busy' },
156+
},
157+
},
158+
}, received)
159+
160+
server_job.stream_api = original_stream_api
161+
end)
162+
163+
it('normalizes /global/event sync payloads into legacy event shape', function()
164+
local server_job = require('opencode.server_job')
165+
local original_stream_api = server_job.stream_api
166+
state.jobs.set_opencode_cli_version('1.14.42')
167+
168+
local received = {}
169+
170+
server_job.stream_api = function(_, _, _, on_chunk)
171+
on_chunk(
172+
'data: ' .. vim.json.encode({
173+
payload = {
174+
type = 'sync',
175+
syncEvent = {
176+
id = 'evt_2',
177+
type = 'message.part.updated.1',
178+
data = {
179+
sessionID = 'ses_1',
180+
part = {
181+
id = 'prt_1',
182+
type = 'text',
183+
text = 'hello',
184+
messageID = 'msg_1',
185+
sessionID = 'ses_1',
186+
},
187+
},
188+
},
189+
id = 'evt_2',
190+
},
191+
})
192+
)
193+
194+
return { shutdown = function() end }
195+
end
196+
197+
local client = api_client.new('http://localhost:8080')
198+
client:subscribe_to_events('/some/directory', function(event)
199+
table.insert(received, event)
200+
end)
201+
202+
assert.same({
203+
{
204+
id = 'evt_2',
205+
type = 'message.part.updated',
206+
properties = {
207+
sessionID = 'ses_1',
208+
part = {
209+
id = 'prt_1',
210+
type = 'text',
211+
text = 'hello',
212+
messageID = 'msg_1',
213+
sessionID = 'ses_1',
214+
},
215+
},
216+
},
217+
}, received)
218+
219+
server_job.stream_api = original_stream_api
220+
end)
107221
end)

0 commit comments

Comments
 (0)