From 774189feea403cfdf6900dec06f6c88b6fec5acb Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Thu, 30 Jul 2026 12:11:50 +0800 Subject: [PATCH] fix(opencode): include session info in session.deleted event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session.deleted event emitted by serve-opencode only carried sessionID with no info field, but the OpenCode TUI reads properties.info.id to optimistically hide deleted sessions (dialog-session-list, app, local store handlers). The mismatch made event.properties.info undefined, so the TUI's deleted-session set was never populated: deleted sessions stayed visible from stale list cache until refetch, and deleting them again hit 404 because the projection row was already gone. Add info: Session to SessionDeletedProperties to match the upstream OpenCode session.deleted schema (which carries both sessionID and info), and pass the full session object from the delete handler. The model and call site are committed together because changing SessionDeletedEvent.create's signature breaks the old call site at runtime. Note: pre-commit ty hook reports 26 pre-existing unresolved-attribute errors on state.pool (AgentPool | None) in session_routes.py that are unrelated to this change (verified by reverting this diff — the same 26 diagnostics appear). They surface in this worktree's ty environment but not in the canonical checkout; CI ty runs in the canonical env and is unaffected. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../opencode_server/models/events.py | 14 ++++++++-- .../opencode_server/routes/session_routes.py | 2 +- .../opencode_server/test_global_event.py | 2 +- .../opencode_server/test_sse_compliance.py | 28 +++++++++++++++++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/agentpool_server/opencode_server/models/events.py b/src/agentpool_server/opencode_server/models/events.py index fa5506bf7..53936f7c2 100644 --- a/src/agentpool_server/opencode_server/models/events.py +++ b/src/agentpool_server/opencode_server/models/events.py @@ -91,7 +91,15 @@ def create(cls, session: Session) -> Self: class SessionDeletedProperties(SessionIdProperties): - """Properties for session deleted event.""" + """Properties for session deleted event. + + Carries the full session ``info`` so OpenCode TUI clients can read + ``properties.info.id`` to optimistically hide deleted sessions, matching + the upstream OpenCode ``session.deleted`` event schema which includes both + ``sessionID`` and ``info``. + """ + + info: Session class SessionDeletedEvent(OpenCodeBaseModel): @@ -101,8 +109,8 @@ class SessionDeletedEvent(OpenCodeBaseModel): properties: SessionDeletedProperties @classmethod - def create(cls, session_id: str) -> Self: - return cls(properties=SessionDeletedProperties(session_id=session_id)) + def create(cls, session: Session) -> Self: + return cls(properties=SessionDeletedProperties(session_id=session.id, info=session)) class SessionStatusProperties(SessionIdProperties): diff --git a/src/agentpool_server/opencode_server/routes/session_routes.py b/src/agentpool_server/opencode_server/routes/session_routes.py index f25168e4f..79a223b9f 100644 --- a/src/agentpool_server/opencode_server/routes/session_routes.py +++ b/src/agentpool_server/opencode_server/routes/session_routes.py @@ -1048,7 +1048,7 @@ async def delete_session(session_id: str, state: StateDep) -> bool: session_pool = state.pool.session_pool if session_pool is not None and session_pool.sessions.store is not None: await session_pool.sessions.store.delete_session(session_id) - await state.broadcast_event(SessionDeletedEvent.create(session_id)) + await state.broadcast_event(SessionDeletedEvent.create(session)) return True diff --git a/tests/servers/opencode_server/test_global_event.py b/tests/servers/opencode_server/test_global_event.py index 6a5ccbfa8..c7e684244 100644 --- a/tests/servers/opencode_server/test_global_event.py +++ b/tests/servers/opencode_server/test_global_event.py @@ -843,7 +843,7 @@ def _build_handled_event(event_type: type) -> Event: # noqa: PLR0911 """Build a handled event with session_id='abc' using the appropriate constructor.""" sid = "abc" if event_type is SessionDeletedEvent: - return SessionDeletedEvent.create(session_id=sid) + return SessionDeletedEvent.create(session=_make_session(sid)) if event_type is SessionStatusEvent: return SessionStatusEvent.create(session_id=sid, status_type="busy") if event_type is SessionIdleEvent: diff --git a/tests/servers/opencode_server/test_sse_compliance.py b/tests/servers/opencode_server/test_sse_compliance.py index 2201b0c73..6f9d9d17f 100644 --- a/tests/servers/opencode_server/test_sse_compliance.py +++ b/tests/servers/opencode_server/test_sse_compliance.py @@ -269,7 +269,7 @@ async def test_tui_filter_all_session_events_pass() -> None: session_events: list[Event] = [ SessionStatusEvent.create(session_id="s1", status_type="busy"), SessionCompactedEvent.create(session_id="s1"), - SessionDeletedEvent.create(session_id="s1"), + SessionDeletedEvent.create(session=_make_session("s1")), TodoUpdatedEvent.create(session_id="s1", todos=[]), PartDeltaEvent.create(session_id="s1", message_id="m1", part_id="p1", delta="x"), ] @@ -473,6 +473,28 @@ async def test_session_id_in_payload_session_created() -> None: assert data["sessionId"] == "top3" +@pytest.mark.anyio +async def test_session_deleted_event_carries_info() -> None: + """SessionDeletedEvent carries full ``info`` for OpenCode TUI clients. + + Clients read ``properties.info.id`` to hide deleted sessions. + + Regression test: the event previously only carried ``sessionID`` with no + ``info`` field, so the OpenCode TUI's ``event.properties.info.id`` access + threw and deleted sessions stayed visible in the session list. + """ + session = _make_session("del1") + event = SessionDeletedEvent.create(session=session) + # model-level: properties expose both sessionID and info.id + assert event.properties.session_id == "del1" + assert event.properties.info.id == "del1" + # wire-level: serialized payload keeps info.id reachable for the TUI + data = json.loads(_serialize_event(event, wrap_payload=False)) + assert data["type"] == "session.deleted" + assert data["properties"]["sessionID"] == "del1" + assert data["properties"]["info"]["id"] == "del1" + + @pytest.mark.anyio async def test_session_id_in_payload_message_updated() -> None: """MessageUpdatedEvent payload has sessionId at top level.""" @@ -949,11 +971,11 @@ def test_session_id_consistency_command_executed() -> None: # Re-use the same event factories from test_global_event.py _ALL_HANDLED_EVENTS_WITH_SID: list[tuple[str, Event]] = [ - ("session.deleted", SessionDeletedEvent.create(session_id="ex1")), + ("session.deleted", SessionDeletedEvent.create(session=_make_session("ex1"))), ("session.status", SessionStatusEvent.create(session_id="ex2", status_type="busy")), ("session.idle", SessionCompactedEvent.create(session_id="ex3")), ("session.compacted", SessionCompactedEvent.create(session_id="ex4")), - ("message.removed", SessionDeletedEvent.create(session_id="ex5")), + ("message.removed", SessionDeletedEvent.create(session=_make_session("ex5"))), ( "message.part.delta", PartDeltaEvent.create(session_id="ex6", message_id="m1", part_id="p1", delta="x"),