Skip to content

Commit a3e038b

Browse files
authored
fix(renderer): scope shared-server events by session (#406)
Route renderer event subscriptions through explicit scope policies so global server events do not leak interactive prompts across sessions. Centralize request ownership checks in session_scope and reuse them from permission/question restore paths. Keep task child-session tool parts working when child events arrive before the parent task part is indexed. Fixes #405
1 parent b977136 commit a3e038b

10 files changed

Lines changed: 467 additions & 102 deletions

lua/opencode/ui/event_scope.lua

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
local state = require('opencode.state')
2+
local session_scope = require('opencode.ui.session_scope')
3+
4+
local M = {}
5+
6+
local function active_session_id()
7+
return state.active_session and state.active_session.id
8+
end
9+
10+
---@param session_id string|nil
11+
---@return boolean
12+
local function active_session(session_id)
13+
if not session_id or session_id == '' then
14+
return false
15+
end
16+
17+
return session_scope.belongs_to_active_session({ sessionID = session_id })
18+
end
19+
20+
---@param properties table|nil
21+
---@return boolean
22+
local function active_session_update(properties)
23+
local session = properties and properties.info
24+
return session and session.id and session.id == active_session_id()
25+
end
26+
27+
---@param properties table|nil
28+
---@return boolean
29+
local function active_message(properties)
30+
local message = properties and properties.info
31+
return active_session(message and message.sessionID)
32+
end
33+
34+
---@param properties table|nil
35+
---@return boolean
36+
local function active_part(properties)
37+
local part = properties and properties.part
38+
if active_session(part and part.sessionID) then
39+
return true
40+
end
41+
42+
-- Task child events may arrive before their parent task part is indexed.
43+
return part
44+
and state.active_session
45+
and part.sessionID
46+
and part.sessionID ~= ''
47+
and (part.tool ~= nil or part.type == 'tool')
48+
end
49+
50+
---@param properties table|nil
51+
---@return boolean
52+
local function active_question_reply(properties)
53+
if not properties or not properties.requestID then
54+
return false
55+
end
56+
57+
return require('opencode.ui.question_window').matches_active_question({
58+
id = properties.requestID,
59+
})
60+
end
61+
62+
---@type table<string, fun(properties: table|nil): boolean>
63+
local policies = {
64+
['session.updated'] = active_session_update,
65+
['session.compacted'] = function(properties)
66+
return active_session(properties and properties.sessionID)
67+
end,
68+
['session.error'] = function(properties)
69+
return active_session(properties and properties.sessionID)
70+
end,
71+
['message.updated'] = active_message,
72+
['message.removed'] = function(properties)
73+
return active_session(properties and properties.sessionID)
74+
end,
75+
['message.part.updated'] = active_part,
76+
['message.part.removed'] = function(properties)
77+
return active_session(properties and properties.sessionID)
78+
end,
79+
['permission.updated'] = session_scope.belongs_to_active_session,
80+
['permission.asked'] = session_scope.belongs_to_active_session,
81+
['permission.replied'] = function(properties)
82+
return active_session(properties and properties.sessionID)
83+
end,
84+
['question.asked'] = session_scope.belongs_to_active_session,
85+
['question.replied'] = active_question_reply,
86+
['question.rejected'] = active_question_reply,
87+
['file.edited'] = function()
88+
return true
89+
end,
90+
['custom.restore_point.created'] = function()
91+
return true
92+
end,
93+
['custom.emit_events.finished'] = function()
94+
return true
95+
end,
96+
}
97+
98+
---@param event_name string
99+
---@return boolean
100+
function M.has_policy(event_name)
101+
return policies[event_name] ~= nil
102+
end
103+
104+
---@param event_name string
105+
---@param properties table|nil
106+
---@return boolean
107+
function M.should_handle(event_name, properties)
108+
local policy = policies[event_name]
109+
if not policy then
110+
return false
111+
end
112+
113+
return policy(properties)
114+
end
115+
116+
local wrappers = {}
117+
118+
---@param event_name string
119+
---@param callback function
120+
---@return function
121+
function M.scoped_callback(event_name, callback)
122+
wrappers[event_name] = wrappers[event_name] or setmetatable({}, { __mode = 'k' })
123+
if not wrappers[event_name][callback] then
124+
wrappers[event_name][callback] = function(properties)
125+
if M.should_handle(event_name, properties) then
126+
callback(properties)
127+
end
128+
end
129+
end
130+
131+
return wrappers[event_name][callback]
132+
end
133+
134+
return M

lua/opencode/ui/permission_window.lua

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local state = require('opencode.state')
22
local Dialog = require('opencode.ui.dialog')
3+
local session_scope = require('opencode.ui.session_scope')
34

45
local M = {}
56

@@ -352,30 +353,10 @@ function M.restore_pending_permissions(session_id)
352353
end
353354

354355
local events = require('opencode.ui.renderer.events')
355-
local render_state = require('opencode.ui.renderer.ctx').render_state
356356

357357
for _, permission in ipairs(permissions) do
358358
if permission and permission.id then
359-
-- Check if this permission belongs to the active session or
360-
-- one of its child sessions (task tool).
361-
local belongs = permission.sessionID == session_id
362-
if not belongs and permission.sessionID and permission.sessionID ~= '' then
363-
belongs = render_state:get_task_part_by_child_session(permission.sessionID) ~= nil
364-
end
365-
if not belongs then
366-
local tool = permission.tool
367-
local tool_message_id = tool and tool.messageID
368-
if tool_message_id and state.messages then
369-
for _, message in ipairs(state.messages) do
370-
if message.info and message.info.id == tool_message_id then
371-
belongs = true
372-
break
373-
end
374-
end
375-
end
376-
end
377-
378-
if belongs and not is_resolved_permission(permission) then
359+
if session_scope.belongs_to_session(permission, session_id) and not is_resolved_permission(permission) then
379360
-- Check if already queued (avoid duplicate)
380361
local already_queued = false
381362
for _, existing in ipairs(M._permission_queue) do

lua/opencode/ui/question_window.lua

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local Dialog = require('opencode.ui.dialog')
44
local Promise = require('opencode.promise')
55

66
local config = require('opencode.config')
7+
local session_scope = require('opencode.ui.session_scope')
78

89
local M = {}
910

@@ -64,36 +65,6 @@ function M.matches_active_question(question_request)
6465
and M._current_question.id == question_request.id
6566
end
6667

67-
---@param question_request OpencodeQuestionRequest|nil
68-
---@return boolean
69-
function M.belongs_to_active_session(question_request)
70-
if not question_request then
71-
return false
72-
end
73-
74-
local active_session = state.active_session
75-
if active_session and active_session.id and question_request.sessionID == active_session.id then
76-
return true
77-
end
78-
79-
local tool = question_request.tool
80-
local tool_message_id = tool and tool.messageID
81-
if tool_message_id and state.messages then
82-
for _, message in ipairs(state.messages) do
83-
if message.info and message.info.id == tool_message_id then
84-
return true
85-
end
86-
end
87-
end
88-
89-
if question_request.sessionID and question_request.sessionID ~= '' then
90-
local render_state = require('opencode.ui.renderer.ctx').render_state
91-
return render_state:get_task_part_by_child_session(question_request.sessionID) ~= nil
92-
end
93-
94-
return false
95-
end
96-
9768
---@param question_request OpencodeQuestionRequest|nil
9869
---@return boolean
9970
local function has_tool_identifiers(question_request)
@@ -221,7 +192,7 @@ function M.restore_pending_question(session_id)
221192
return Promise.new():resolve(nil)
222193
end
223194

224-
if M.has_question() and M.belongs_to_active_session(M._current_question) then
195+
if M.has_question() and session_scope.belongs_to_active_session(M._current_question) then
225196
if not is_resolved_question_request(M._current_question) then
226197
return Promise.new():resolve(nil)
227198
end
@@ -238,11 +209,11 @@ function M.restore_pending_question(session_id)
238209

239210
for _, request in ipairs(requests) do
240211
if
241-
request
242-
and request.questions
243-
and #request.questions > 0
244-
and M.belongs_to_active_session(request)
245-
and not is_resolved_question_request(request)
212+
request
213+
and request.questions
214+
and #request.questions > 0
215+
and session_scope.belongs_to_active_session(request)
216+
and not is_resolved_question_request(request)
246217
then
247218
if M.matches_active_question(request) then
248219
return

lua/opencode/ui/renderer.lua

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ local permission_window = require('opencode.ui.permission_window')
55
local Promise = require('opencode.promise')
66
local ctx = require('opencode.ui.renderer.ctx')
77
local events = require('opencode.ui.renderer.events')
8+
local event_scope = require('opencode.ui.event_scope')
89
local flush = require('opencode.ui.renderer.flush')
910
local scroll = require('opencode.ui.renderer.scroll')
1011

@@ -259,6 +260,27 @@ end
259260
-- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data'))
260261
M.on_session_updated = events.on_session_updated
261262

263+
function M.event_subscriptions()
264+
return {
265+
{ 'session.updated', events.on_session_updated },
266+
{ 'session.compacted', events.on_session_compacted },
267+
{ 'session.error', events.on_session_error },
268+
{ 'message.updated', events.on_message_updated },
269+
{ 'message.removed', events.on_message_removed },
270+
{ 'message.part.updated', events.on_part_updated },
271+
{ 'message.part.removed', events.on_part_removed },
272+
{ 'permission.updated', events.on_permission_updated },
273+
{ 'permission.asked', events.on_permission_updated },
274+
{ 'permission.replied', events.on_permission_replied },
275+
{ 'question.asked', events.on_question_asked },
276+
{ 'question.replied', events.on_question_replied },
277+
{ 'question.rejected', events.on_question_replied },
278+
{ 'file.edited', events.on_file_edited },
279+
{ 'custom.restore_point.created', events.on_restore_points },
280+
{ 'custom.emit_events.finished', M.on_emit_events_finished },
281+
}
282+
end
283+
262284
---Reset all renderer state and clear the output buffer
263285
function M.reset()
264286
ctx:reset()
@@ -291,30 +313,12 @@ function M.setup_subscriptions(subscribe)
291313
return
292314
end
293315

294-
local subs = {
295-
{ 'session.updated', events.on_session_updated },
296-
{ 'session.compacted', events.on_session_compacted },
297-
{ 'session.error', events.on_session_error },
298-
{ 'message.updated', events.on_message_updated },
299-
{ 'message.removed', events.on_message_removed },
300-
{ 'message.part.updated', events.on_part_updated },
301-
{ 'message.part.removed', events.on_part_removed },
302-
{ 'permission.updated', events.on_permission_updated },
303-
{ 'permission.asked', events.on_permission_updated },
304-
{ 'permission.replied', events.on_permission_replied },
305-
{ 'question.asked', events.on_question_asked },
306-
{ 'question.replied', events.clear_question_display },
307-
{ 'question.rejected', events.clear_question_display },
308-
{ 'file.edited', events.on_file_edited },
309-
{ 'custom.restore_point.created', events.on_restore_points },
310-
{ 'custom.emit_events.finished', M.on_emit_events_finished },
311-
}
312-
313-
for _, sub in ipairs(subs) do
316+
for _, sub in ipairs(M.event_subscriptions()) do
317+
local callback = event_scope.scoped_callback(sub[1], sub[2])
314318
if subscribe then
315-
state.event_manager:subscribe(sub[1], sub[2])
319+
state.event_manager:subscribe(sub[1], callback)
316320
else
317-
state.event_manager:unsubscribe(sub[1], sub[2])
321+
state.event_manager:unsubscribe(sub[1], callback)
318322
end
319323
end
320324
end

lua/opencode/ui/renderer/events.lua

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -504,10 +504,6 @@ function M.on_permission_updated(permission)
504504
return
505505
end
506506

507-
local tool = permission.tool
508-
local callID = tool and tool.callID or permission.callID
509-
local messageID = tool and tool.messageID or permission.messageID
510-
511507
if not state.pending_permissions then
512508
state.renderer.set_pending_permissions({})
513509
end
@@ -561,7 +557,12 @@ function M.on_question_asked(properties)
561557
if not properties or not properties.id or not properties.questions then
562558
return
563559
end
564-
require('opencode.ui.question_window').show_question(properties)
560+
local question_window = require('opencode.ui.question_window')
561+
question_window.show_question(properties)
562+
end
563+
564+
function M.on_question_replied()
565+
M.clear_question_display()
565566
end
566567

567568
---Handle file.edited — reload buffers and fire the hook

lua/opencode/ui/session_scope.lua

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
local state = require('opencode.state')
2+
3+
local M = {}
4+
5+
---@param request table|nil
6+
---@return string|nil
7+
local function get_message_id(request)
8+
if not request then
9+
return nil
10+
end
11+
12+
local tool = request.tool
13+
return (tool and tool.messageID) or request.messageID
14+
end
15+
16+
---@param request table|nil
17+
---@param session_id string|nil
18+
---@return boolean
19+
function M.belongs_to_session(request, session_id)
20+
if not request then
21+
return false
22+
end
23+
24+
if request.sessionID and request.sessionID ~= '' then
25+
if request.sessionID == session_id then
26+
return true
27+
end
28+
29+
local render_state = require('opencode.ui.renderer.ctx').render_state
30+
if render_state:get_task_part_by_child_session(request.sessionID) ~= nil then
31+
return true
32+
end
33+
end
34+
35+
local message_id = get_message_id(request)
36+
if message_id and state.messages then
37+
for _, message in ipairs(state.messages) do
38+
if message.info and message.info.id == message_id then
39+
return true
40+
end
41+
end
42+
end
43+
44+
return (not request.sessionID or request.sessionID == '') and session_id ~= nil and session_id ~= ''
45+
end
46+
47+
---@param request table|nil
48+
---@return boolean
49+
function M.belongs_to_active_session(request)
50+
local active_session = state.active_session
51+
return M.belongs_to_session(request, active_session and active_session.id)
52+
end
53+
54+
return M

0 commit comments

Comments
 (0)