feat(opencode-server): OpenCode v1.4.4+ compatibility — GlobalEvent SSE, async prompt queue, session sync fixes - #21
Conversation
…ration for v1.4.4+ SSE protocol
…add protocol tests
…exhaustiveness warning - Add global_router to conftest.py app fixture for SSE integration testing - Remove unused MemoryStorageProvider import from conftest.py - Add logger.warning for unhandled event types in _extract_session_id wildcard case
…ption isolation - Add SSEStream class and global_event_stream fixture to conftest.py - Add parse_sse_event helper for SSE data line parsing - Add exception isolation to broadcast_event: iterate copy, put_nowait, catch QueueFull - Safe queue removal in _event_generator finally block with contextlib.suppress - Add race condition analysis comment for on_first_subscriber callback
…iff.patch, workspace routing, session_id extraction - Migrate top-level variant into model.variant for UserMessage/MessageRequest (backward-compat) - Replace FileDiff.before/after with FileDiff.patch matching OpenCode v1.4.0+ schema - Add WorkspaceInfo/WorkspaceConnectionStatus models and /experimental/workspace endpoints - Add workspace field to GlobalEvent envelope for TUI routing - Fix base_path to anchor on server working_dir, not agent env.cwd - Extend _extract_session_id for 6 additional event types (22 total) - Add /global/diagnostic and /global/routing-check endpoints - Add DiagnosticResponse model - Update tests for new models and routing behavior
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Keep OpenCode question prompts on the que_* namespace while preserving the legacy permission bridge through /question. The regression tests cover both the new question prefix and the fallback permission reply path. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Keep /global/event fully envelope-shaped so 1.4.4+ clients keep consuming after idle timeouts. Normalizing the routing directory in ServerState keeps the envelope aligned with the TUI filter. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refresh the compliance suite for wrapped heartbeat events and normalized routing metadata. This keeps the regression coverage aligned with the updated /global/event contract. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…wrap() Add explicit no-session case block in _extract_session_id for all 16 event types that have no session association (ServerHeartbeatEvent, ServerConnectedEvent, FileWatcherUpdatedEvent, FileEditedEvent, etc.), eliminating spurious logger.warning spam on every broadcast. Replace redundant json.loads(_serialize_event()) in GlobalEventFactory.wrap() with direct model_dump() for better performance. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Expand _extract_session_id tests: verify all 16 no-session events return None without warnings, add test for unknown event types still triggering the case _ warning fallback, update docstrings to reflect GlobalEventFactory.wrap() using model_dump directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ribute access Add public has_pending_permission() method to OpenCodeInputProvider and use it in permission_routes.py instead of directly accessing _pending_permissions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…t race Refactor _serialize_event into _event_to_dict (dict) + _serialize_event (string wrapper) so GlobalEventFactory.wrap() and _event_generator avoid redundant serialization round-trips. Add docstring comments in session_routes.py noting the shared-agent singleton race condition for multi-session scenarios. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…torage Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… Logfire-compatible structured logging config_routes.py was the only file using standard logging.getLogger(), which doesn't accept keyword arguments like count=, error=, model=. All other opencode_server files use agentpool.log.get_logger() which returns a Logfire-compatible logger that supports structured kwargs. Without Logfire active, the standard logger throws TypeError on kwargs, causing 500 errors on GET /provider and GET /config/providers.
… for TUI routing
opencode 1.4.11 TUI workspace filter silently drops events whose workspace field does not match the active workspace. GlobalEventFactory was created with workspace=None, so all agentpool events were dropped when any workspace was active. Derive workspace as wrk_{project[:12]} to match the /experimental/workspace API id format.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…a for TUI consistency ToolPart metadata was inconsistent between _process_spawn_start (missing title) and _process_subagent_event (has title). The TUI relies on metadata.title for subagent display, so the initial ToolPart created by SpawnSessionStart must include it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…Part subagent_type ToolPart input.subagent_type was set to source_type (generic category like 'agent', 'team_parallel') but opencode's TUI taskAgent() matches against the agent registry by name (e.g., 'explore', 'general'). Using source_name allows the TUI to display the correct agent name and color instead of a generic 'Agent'. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…on routing
opencode 1.4.11 TUI filters sessions by workspaceID. Without it, child
sessions created by subagent spawning are invisible in the TUI session
list and the subagent view is not clickable.
- Add workspace_id: str | None = None to Session model
- Set workspace_id=f"wrk_{project_id[:12]}" in all 3 creation sites:
session_routes.py create_session(), fork session, state.py ensure_session()
- session_data_to_opencode() derives workspace_id from metadata or project_id
- opencode_to_session_data() persists workspace_id in metadata dict
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- test_ensure_session_creates_new_session: assert workspace_id == wrk_global - test_ensure_session_returns_existing_session: include workspace_id in mock - test_spawn_start_child_session_has_workspace_id: new test verifying child sessions created by SpawnSessionStart have correct workspace_id derived from project_id Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…d ContextVar reset - SessionManager.create_child_session() now inherits project_id and cwd from parent session, preventing TUI workspace filter from dropping child sessions that fell back to 'default'/'global' project IDs - ACPSessionManager.create_session() now computes project_id from cwd using compute_project_id(), fixing ACP sessions with None project_id - session_data_to_opencode() converter recomputes project_id from cwd when stored value is None or 'default' (read-side fallback for historical sessions persisted with incorrect project_id) - ServerState.ensure_session() skips agent binding for child sessions to prevent deadlock on agent_lock held by parent stream and avoid overwriting parent's session_id on the shared agent - BaseAgent suppresses ValueError on ContextVar.reset() during GeneratorExit cleanup in different async contexts - Added tests for project_id inheritance and child session binding skip
Child subagent sessions emit StepFinishPart events that bubble through process_stream(). Previously, any StepFinishPart (including children's) would set _step_finish_emitted=True. If the parent stream was interrupted after a child completed but before the parent's StreamCompleteEvent, finalize() would skip emitting the parent's StepFinishPart — leaving the parent session stuck in 'working' state in the TUI. Now only StepFinishPart events whose session_id matches the adapter's session_id trigger _step_finish_emitted, so child StepFinishParts cannot suppress the parent's fallback.
… UnboundLocalError When CancelledError fires before the agent_lock is acquired, the except handler at line ~518 references the local variable which was only defined inside . This causes UnboundLocalError crash in the error handler itself. Fix: Move before the block so the except handler always has a valid reference. Test: test_question_abort_regression.py::TestUnboundLocalErrorInExceptHandler
…essage processing When question_for_user raises RunAbortedError (user cancels questionnaire), process_stream caught it via except Exception and yielded SessionErrorEvent, then the iterator completed normally. This meant _process_message_locked followed the normal completion path which: 1. Did NOT set MessageAbortedError on the assistant message 2. Did NOT add the aborted assistant message to agent.conversation.chat_messages This corrupted the agent conversation history - on the next message, the LLM saw a partial tool call (question_for_user called but no result), causing unpredictable behavior and eventually the TUI black screen. Fix: - process_stream: Re-raise RunAbortedError (like CancelledError) instead of swallowing it, so the caller can properly handle it - _process_message_locked: Add RunAbortedError to the except clause alongside CancelledError and TimeoutError, with proper reason string Tests: test_question_abort_regression.py (all 5 RunAbortedError tests now pass)
…revent agent_lock deadlock When the agent awaits a question answer (Future.await) while holding agent_lock, and the TUI disconnects (SSE connection drops), the Future is never resolved and agent_lock is never released. On reconnect, all requests needing agent_lock deadlock permanently. Fix: - Add ServerState.cancel_all_pending_questions() which cancels all pending question Futures and returns their IDs - Call it from the SSE disconnect handler in global_routes.py _event_generator - When a question Future is cancelled, CancelledError propagates through input_provider -> question_for_user -> RunAbortedError -> process_stream (re-raises) -> _process_message_locked (handles like CancelledError) -> agent_lock released, session marked idle Tests: - test_cancel_all_pending_questions_cancels_futures: verifies Future cancellation releases agent_lock - test_cancel_all_pending_questions_allows_new_message_after_sse_disconnect: verifies new session message succeeds after disconnect
…de_server test suite
Root causes and fixes:
1. test_cancelled_message.py (6 failures):
- MessageRequest.variant does not exist (variant is on ModelRef, not MessageRequest)
- UserMessage also has no variant field
- Fix: Remove variant=request.variant from _create_user_message helper
2. test_command_execution.py (6 failures):
- pool.skill_commands was a Mock (truthy but not iterable)
- session_routes.py execute_command checks 'request.command in state.pool.skill_commands'
- Fix: Set pool.skill_commands = None in conftest mock_pool
3. test_session_cwd_consistency.py (2 failures):
- Tests expected create_session to use env.cwd for session.directory
- But state.base_path explicitly uses working_dir for routing, not env.cwd
- env.cwd is for agent tool isolation, routing anchors to working_dir
- Fix: Update tests to expect working_dir-based directory
4. test_session_title_fixes.py + test_session_title_integration.py (7 failures):
- pool.sessions.store was AsyncMock (saves absorbed, load_session returns None)
- _generate_title_from_prompt did not persist title when _generate_title_core was mocked
- agent.list_sessions was AsyncMock(return_value=[]) (created sessions invisible)
- Fix: Wire pool.sessions.store to storage_manager, add update_session_title
call in _generate_title_from_prompt, delegate agent.list_sessions to storage
Result: 515 passed, 0 failed, 9 xpassed (was 494 passed, 27 failed)
…returns None
Root cause: When the TUI creates multiple sessions and then sends messages
to each, POST /session/{id}/message returns 404 for all but the last
session. This happens because:
1. create_session saves to pool.sessions.store (MemorySessionStore)
2. agent.load_session reads from pool.storage (StorageManager/SQL)
3. These are DISCONNECTED storage backends
4. get_or_load_session only returned cached sessions when
agent.session_id matched, falling through to agent.load_session
for all others — which returns None → 404
Fix in get_or_load_session:
- Return cached sessions from state.sessions even when agent.session_id
doesn't match and agent.load_session returns None
- Try to reload history from storage, but gracefully handle the case
where the session hasn't been persisted to StorageManager yet
Fix in create_session:
- Also save to pool.storage (StorageManager) so agent.load_session
can find the session, not just pool.sessions.store (MemorySessionStore)
Red flag test: test_session_cache_404.py (5 tests) reproduces the exact
TUI pattern: create 4 sessions, send message to each → was 404, now 200
520 passed, 0 failed (was 515 passed)
…s sessions immediately The CLI TUI's sync.tsx handles session.updated (upsert) but NOT session.created (insert-only). Without a session.updated SSE event, the TUI relies solely on the async REST session.sync() call to populate its SolidJS store, causing a black screen while the store is empty. Fixes: - ensure_session() early return now broadcasts SessionUpdatedEvent (previously silently returned without any event) - get_or_load_session() cold-load path broadcasts SessionUpdatedEvent (previously loaded from storage with no SSE notification) - create_session() now broadcasts SessionUpdatedEvent alongside SessionCreatedEvent (matching official OpenCode server behavior) - fork_session() now broadcasts SessionUpdatedEvent alongside SessionCreatedEvent Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add Cache-Control, X-Accel-Buffering, and X-Content-Type-Options headers to /global/event and /event SSE endpoints, matching the official OpenCode server behavior. Without these, reverse proxies (nginx, Cloudflare) may buffer SSE events causing delayed delivery. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… SQLModelProvider - SQLModelProvider session methods now use self.engine directly instead of creating one-shot SQLSessionStore instances that disposed the shared engine - SQLSessionStore.__aexit__ no longer disposes shared engine from get_shared_engine() - Add load_sessions_batch() for batch session loading Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Replace N+1 load_session calls with single load_sessions_batch query - Add StorageManager.load_sessions_batch() delegating to provider - Fix dead 'return result' after refactoring Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
_generate_title_from_prompt() was skipping generation because 'if existing:' was truthy for the default 'New Session' title. Now checks 'if existing and existing != "New Session"' so generation proceeds when only the placeholder is set. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…_model _generate_title_core() was passing title_generation_model directly to infer_model(), which fails for variant names like 'ack-dev' with 'Unknown model' error. Now checks _model_variants first (populated by AgentPool from manifest.model_variants) and only falls back to infer_model for non-variant model strings. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Conversation.title is now the single source of truth. _session_from_db and _from_db_model always sync row.title into metadata['title'] instead of skipping when a stale entry exists. This fixes the bug where update_session_title only wrote the column but load_session returned the stale 'New Session' from metadata_json. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
When SpawnSessionStart has no description but metadata.prompt contains the actual task, the child session first user message now correctly shows the prompt content instead of a generic 'Task: {source_name}' fallback.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…e text parts The TUI's UserMessage component renders text from store.part[message.id]. Without PartUpdatedEvent, the store has the message but no parts, so the first user message card in a subagent session appears empty. Add PartUpdatedEvent.create() yields after add_text_part() in both _process_spawn_start and _process_subagent_event fallback paths. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Parent agent holds agent_lock during entire streaming. When TUI navigates to a child session, REST endpoints call get_or_load_session which acquires agent_lock, blocking until the parent finishes. Add fast-path for child sessions (parent_id is set) that returns data directly from in-memory state without acquiring agent_lock. This is safe because child sessions never rebind the shared agent. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… for TUI upsert The CLI TUI's sync.tsx handles session.updated (upsert into store) but does not handle session.created (insert-only). Without also broadcasting SessionUpdatedEvent, child sessions created by ensure_session are invisible to the TUI until a REST API sync. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
OpenCode's workspace feature is entirely optional and behind an experimental flag. AgentPool not sending workspace fields is fully backward-compatible with all OpenCode versions. Remove workspace code to simplify the opencode server. Removed: - WorkspaceInfo, WorkspaceConnectionStatus, WorkspaceConnectionState models - workspace field from GlobalEvent - workspace_id field from Session - /experimental/workspace and /experimental/workspace/status endpoints - Workspace routing rule (3) from tui_event_filter (4 rules → 3 rules) - workspace param from GlobalEventFactory and routing-check endpoint - workspace_id from session creation and metadata persistence - All workspace-related tests (test_workspace_routes.py deleted)
There was a problem hiding this comment.
Code Review
This pull request implements OpenCode 1.4.4+ compatibility, featuring OTLP telemetry sinks, global management routes, and a revised SSE protocol using GlobalEvent envelopes. It resolves session synchronization issues by ensuring child sessions inherit project metadata and introduces per-session async prompt queuing to prevent UI blocking. The storage layer is optimized with batch session loading and project persistence. Feedback highlights the need to refactor duplicated fast-path logic for child sessions into a shared helper, refine session persistence by narrowing exception handling, and replace the non-atomic 'delete-then-insert' upsert pattern in the SQL provider with ON CONFLICT statements to ensure data integrity under concurrency.
Summary
Upgrade OpenCode server to v1.4.4+ protocol compatibility, fixing session synchronization, SSE event routing, and concurrency issues that caused deadlocks and data loss in subagent workflows.
Changes
OpenCode v1.4.4+ Protocol Support
GlobalEventenvelope model, factory, andServerStateintegration for the new SSE protocol/global/health,/global/event,/global/sessions) for newer clientsGlobalEventenvelopes with workspace routingworkspace_idtoSessionmodel for TUI session routingSession Synchronization & Subagent Fixes
project_idinheritance — subagents now correctly inherit parent project metadataagent_lockdeadlock: hoist agent assignment before lock acquisition, skip lock for child session REST endpointsContextVarreset on subagent exit that caused state leakage_step_finish_emittedto parent session only, preventing premature stream terminationagent_lockdeadlockRunAbortedErrorlikeCancelledErrorin message processingPartUpdatedEventfor subagent user message text partsSSE & Event Protocol
X-Accel-Buffering: no,Cache-Control: no-cache) to event endpointssource_namevssource_typeforToolPartsubagent metadataSpawnSessionStartToolPart metadata for TUI consistencyget_logger) instead oflogging.getLoggerfor Logfire compatibilityStorage Layer
model_variantsin title generation beforeinfer_model"New Session"existsSQLModelProviderto avoid accidentaldispose()list_sessionsinstead of N+1 per-session loadsAsync Prompt Queuing
Refactoring
SessionIdPropertiesbase class for cleaner variant handling_event_to_dicthelper and document shared-agent race conditionshas_pending_permissionproperty to avoid private attribute accessTests (36 files, +7644 lines)
Squash Merge Message