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"),