feat(omp-rpc): introduced typed event listeners and todo phase management to RPC client

- Added typed event listeners and granular event handling for all RPC notification types.
- Added set_todos RPC command and todoPhases session state field for todo phase management.
- Added RpcClient initialization parameters (thinking, tools, no_session, rpc_defaults) for startup configuration.
- Added install_headless_ui() method and todo management methods (get_todos, set_todos, clear_todos).
- Added TodoItem and TodoPhase dataclasses with parser functions for structured todo representation.
- Added RPC mode behavior: disables session title generation by default and resets workflow settings to built-in defaults.
This commit is contained in:
can1357
2026-04-08 04:52:46 +02:00
parent 67e12b39fe
commit d7261bcbeb
11 changed files with 740 additions and 17 deletions
+321 -6
View File
@@ -11,9 +11,14 @@ from pathlib import Path
from typing import Any, Callable, Mapping, Sequence, TypeVar, cast
from .protocol import (
AgentStartEvent,
AgentEndEvent,
AgentMessage,
AssistantMessage,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
BashResult,
BranchMessage,
BranchResult,
@@ -25,16 +30,34 @@ from .protocol import (
InterruptMode,
JsonObject,
JsonValue,
MessageEndEvent,
MessageStartEvent,
MessageUpdateEvent,
ModelCycleResult,
ModelInfo,
ReadyEvent,
RetryFallbackAppliedEvent,
RetryFallbackSucceededEvent,
RpcAgentEvent,
RpcNotification,
SessionState,
SessionStats,
SteeringMode,
StreamingBehavior,
ThinkingLevel,
ThinkingLevelCycleResult,
TodoItem,
TodoPhase,
TodoStatus,
TodoAutoClearEvent,
TodoReminderEvent,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
UnknownNotification,
assistant_text,
parse_bash_result,
parse_branch_messages,
@@ -47,12 +70,36 @@ from .protocol import (
parse_session_state,
parse_session_stats,
parse_thinking_level_cycle_result,
parse_todo_phases,
)
AgentEventListener = Callable[[RpcAgentEvent], None]
NotificationListener = Callable[[RpcNotification], None]
UiRequestListener = Callable[[ExtensionUiRequest], None]
ExtensionErrorListener = Callable[[ExtensionError], None]
ReadyListener = Callable[[ReadyEvent], None]
UnknownNotificationListener = Callable[[UnknownNotification], None]
AgentStartListener = Callable[[AgentStartEvent], None]
AgentEndListener = Callable[[AgentEndEvent], None]
TurnStartListener = Callable[[TurnStartEvent], None]
TurnEndListener = Callable[[TurnEndEvent], None]
MessageStartListener = Callable[[MessageStartEvent], None]
MessageUpdateListener = Callable[[MessageUpdateEvent], None]
MessageEndListener = Callable[[MessageEndEvent], None]
ToolExecutionStartListener = Callable[[ToolExecutionStartEvent], None]
ToolExecutionUpdateListener = Callable[[ToolExecutionUpdateEvent], None]
ToolExecutionEndListener = Callable[[ToolExecutionEndEvent], None]
AutoCompactionStartListener = Callable[[AutoCompactionStartEvent], None]
AutoCompactionEndListener = Callable[[AutoCompactionEndEvent], None]
AutoRetryStartListener = Callable[[AutoRetryStartEvent], None]
AutoRetryEndListener = Callable[[AutoRetryEndEvent], None]
RetryFallbackAppliedListener = Callable[[RetryFallbackAppliedEvent], None]
RetryFallbackSucceededListener = Callable[[RetryFallbackSucceededEvent], None]
TtsrTriggeredListener = Callable[[TtsrTriggeredEvent], None]
TodoReminderListener = Callable[[TodoReminderEvent], None]
TodoAutoClearListener = Callable[[TodoAutoClearEvent], None]
TListener = TypeVar("TListener")
TEventListener = TypeVar("TEventListener", bound=Callable[..., None])
class RpcError(RuntimeError):
@@ -89,6 +136,10 @@ class PromptTurn:
return self.assistant_text
TodoSeed = str | TodoItem | Mapping[str, object]
TodoPhaseSeed = TodoPhase | Mapping[str, object]
class RpcClient:
def __init__(
self,
@@ -100,6 +151,15 @@ class RpcClient:
session_dir: str | Path | None = None,
cwd: str | Path | None = None,
env: Mapping[str, str] | None = None,
thinking: ThinkingLevel | None = None,
append_system_prompt: str | None = None,
provider_session_id: str | None = None,
tools: Sequence[str] | None = None,
no_session: bool = False,
no_skills: bool = False,
no_rules: bool = False,
no_title: bool | None = None,
rpc_defaults: bool = True,
extra_args: Sequence[str] = (),
startup_timeout: float = 30.0,
request_timeout: float = 30.0,
@@ -111,6 +171,15 @@ class RpcClient:
self._session_dir = Path(session_dir) if session_dir is not None else None
self._cwd = Path(cwd) if cwd is not None else None
self._env = dict(env or {})
self._thinking = thinking
self._append_system_prompt = append_system_prompt
self._provider_session_id = provider_session_id
self._tools = tuple(tools) if tools is not None else None
self._no_session = no_session
self._no_skills = no_skills
self._no_rules = no_rules
self._no_title = no_title
self._rpc_defaults = rpc_defaults
self._extra_args = tuple(extra_args)
self._startup_timeout = startup_timeout
self._request_timeout = request_timeout
@@ -130,7 +199,11 @@ class RpcClient:
self._closed_error: BaseException | None = None
self._stopping = False
self._notification_listeners: list[NotificationListener] = []
self._event_listeners: list[AgentEventListener] = []
self._typed_event_listeners: dict[str, list[AgentEventListener]] = {}
self._ready_listeners: list[ReadyListener] = []
self._unknown_notification_listeners: list[UnknownNotificationListener] = []
self._ui_request_listeners: list[UiRequestListener] = []
self._extension_error_listeners: list[ExtensionErrorListener] = []
@@ -232,6 +305,71 @@ class RpcClient:
self._event_listeners.append(listener)
return lambda: self._remove_listener(self._event_listeners, listener)
def on_notification(self, listener: NotificationListener) -> Callable[[], None]:
self._notification_listeners.append(listener)
return lambda: self._remove_listener(self._notification_listeners, listener)
def on_ready(self, listener: ReadyListener) -> Callable[[], None]:
self._ready_listeners.append(listener)
return lambda: self._remove_listener(self._ready_listeners, listener)
def on_agent_start(self, listener: AgentStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("agent_start", listener)
def on_agent_end(self, listener: AgentEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("agent_end", listener)
def on_turn_start(self, listener: TurnStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("turn_start", listener)
def on_turn_end(self, listener: TurnEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("turn_end", listener)
def on_message_start(self, listener: MessageStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("message_start", listener)
def on_message_update(self, listener: MessageUpdateListener) -> Callable[[], None]:
return self._add_typed_event_listener("message_update", listener)
def on_message_end(self, listener: MessageEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("message_end", listener)
def on_tool_execution_start(self, listener: ToolExecutionStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("tool_execution_start", listener)
def on_tool_execution_update(self, listener: ToolExecutionUpdateListener) -> Callable[[], None]:
return self._add_typed_event_listener("tool_execution_update", listener)
def on_tool_execution_end(self, listener: ToolExecutionEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("tool_execution_end", listener)
def on_auto_compaction_start(self, listener: AutoCompactionStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("auto_compaction_start", listener)
def on_auto_compaction_end(self, listener: AutoCompactionEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("auto_compaction_end", listener)
def on_auto_retry_start(self, listener: AutoRetryStartListener) -> Callable[[], None]:
return self._add_typed_event_listener("auto_retry_start", listener)
def on_auto_retry_end(self, listener: AutoRetryEndListener) -> Callable[[], None]:
return self._add_typed_event_listener("auto_retry_end", listener)
def on_retry_fallback_applied(self, listener: RetryFallbackAppliedListener) -> Callable[[], None]:
return self._add_typed_event_listener("retry_fallback_applied", listener)
def on_retry_fallback_succeeded(self, listener: RetryFallbackSucceededListener) -> Callable[[], None]:
return self._add_typed_event_listener("retry_fallback_succeeded", listener)
def on_ttsr_triggered(self, listener: TtsrTriggeredListener) -> Callable[[], None]:
return self._add_typed_event_listener("ttsr_triggered", listener)
def on_todo_reminder(self, listener: TodoReminderListener) -> Callable[[], None]:
return self._add_typed_event_listener("todo_reminder", listener)
def on_todo_auto_clear(self, listener: TodoAutoClearListener) -> Callable[[], None]:
return self._add_typed_event_listener("todo_auto_clear", listener)
def on_ui_request(self, listener: UiRequestListener) -> Callable[[], None]:
self._ui_request_listeners.append(listener)
return lambda: self._remove_listener(self._ui_request_listeners, listener)
@@ -240,6 +378,55 @@ class RpcClient:
self._extension_error_listeners.append(listener)
return lambda: self._remove_listener(self._extension_error_listeners, listener)
def on_unknown_notification(self, listener: UnknownNotificationListener) -> Callable[[], None]:
self._unknown_notification_listeners.append(listener)
return lambda: self._remove_listener(self._unknown_notification_listeners, listener)
def install_headless_ui(
self,
*,
on_request: UiRequestListener | None = None,
confirm: bool = False,
select_value: str | None = None,
input_value: str | None = None,
editor_value: str | None = None,
) -> Callable[[], None]:
"""Auto-handle RPC UI requests for non-interactive hosts.
Passive UI methods such as notifications and status updates are ignored.
Confirm dialogs default to `False`. Select, input, and editor requests
are cancelled unless an explicit value is provided.
"""
def handle(request: ExtensionUiRequest) -> None:
if on_request is not None:
on_request(request)
if request.method == "cancel" or request.is_passive():
return
if request.method == "confirm":
self.send_ui_confirmation(request.id, confirm)
return
if request.method == "select":
if select_value is not None:
self.send_ui_value(request.id, select_value)
else:
self.cancel_ui_request(request.id)
return
if request.method == "input":
if input_value is not None:
self.send_ui_value(request.id, input_value)
else:
self.cancel_ui_request(request.id)
return
if request.method == "editor":
if editor_value is not None:
self.send_ui_value(request.id, editor_value)
else:
self.cancel_ui_request(request.id)
return self.on_ui_request(handle)
def next_ui_request(self, timeout: float | None = None) -> ExtensionUiRequest:
try:
return self._ui_requests.get(timeout=timeout)
@@ -340,6 +527,17 @@ class RpcClient:
def set_session_name(self, name: str) -> None:
self._request("set_session_name", name=name)
def get_todos(self) -> tuple[TodoPhase, ...]:
return self.get_state().todo_phases
def set_todos(self, todos: Sequence[TodoSeed | TodoPhaseSeed]) -> tuple[TodoPhase, ...]:
phases = self._normalize_todo_phases(todos)
payload = self._request("set_todos", phases=cast(JsonValue, phases))
return parse_todo_phases(payload.get("todoPhases"))
def clear_todos(self) -> tuple[TodoPhase, ...]:
return self.set_todos(())
def get_messages(self) -> tuple[AgentMessage, ...]:
payload = self._request("get_messages")
return tuple(cast(list[AgentMessage], payload.get("messages") or []))
@@ -476,6 +674,91 @@ class RpcClient:
process = self._require_process()
self._write_json(process, payload)
def _add_typed_event_listener(self, event_type: str, listener: TEventListener) -> Callable[[], None]:
listeners = self._typed_event_listeners.setdefault(event_type, [])
typed_listener = cast(AgentEventListener, listener)
listeners.append(typed_listener)
return lambda: self._remove_listener(listeners, typed_listener)
@staticmethod
def _normalize_todo_phases(todos: Sequence[TodoSeed | TodoPhaseSeed]) -> list[JsonObject]:
if len(todos) == 0:
return []
next_task_id = 1
def next_task() -> str:
nonlocal next_task_id
task_id = f"task-{next_task_id}"
next_task_id += 1
return task_id
def normalize_todo_item(seed: TodoSeed) -> JsonObject:
if isinstance(seed, str):
return {"id": next_task(), "content": seed, "status": cast(JsonValue, "pending")}
if isinstance(seed, TodoItem):
return {
"id": seed.id or next_task(),
"content": seed.content,
"status": cast(JsonValue, seed.status),
"notes": seed.notes,
"details": seed.details,
}
content = seed.get("content")
if not isinstance(content, str) or not content.strip():
raise RpcError("Todo items must provide a non-empty 'content' value")
raw_id = seed.get("id")
raw_status = seed.get("status")
raw_notes = seed.get("notes")
raw_details = seed.get("details")
status: TodoStatus = cast(TodoStatus, raw_status) if isinstance(raw_status, str) else "pending"
return {
"id": str(raw_id) if isinstance(raw_id, str) and raw_id else next_task(),
"content": content,
"status": cast(JsonValue, status),
"notes": raw_notes if isinstance(raw_notes, str) else None,
"details": raw_details if isinstance(raw_details, str) else None,
}
def is_phase_seed(seed: TodoSeed | TodoPhaseSeed) -> bool:
if isinstance(seed, TodoPhase):
return True
if not isinstance(seed, Mapping):
return False
return "tasks" in seed or ("name" in seed and "content" not in seed)
def normalize_phase(seed: TodoPhaseSeed, index: int) -> JsonObject:
if isinstance(seed, TodoPhase):
phase_id = seed.id or f"phase-{index}"
name = seed.name
tasks = [normalize_todo_item(task) for task in seed.tasks]
else:
raw_name = seed.get("name")
if not isinstance(raw_name, str) or not raw_name.strip():
raise RpcError("Todo phases must provide a non-empty 'name' value")
phase_id_value = seed.get("id")
raw_tasks = seed.get("tasks") or ()
if not isinstance(raw_tasks, Sequence) or isinstance(raw_tasks, (str, bytes)):
raise RpcError("Todo phase 'tasks' must be a sequence")
phase_id = str(phase_id_value) if isinstance(phase_id_value, str) and phase_id_value else f"phase-{index}"
name = raw_name
tasks = [normalize_todo_item(cast(TodoSeed, task)) for task in raw_tasks]
return {"id": phase_id, "name": name, "tasks": tasks}
if any(is_phase_seed(todo) for todo in todos):
phases: list[JsonObject] = []
for index, seed in enumerate(todos, start=1):
if not is_phase_seed(seed):
raise RpcError("Cannot mix flat todo items with todo phases in one set_todos() call")
phases.append(normalize_phase(cast(TodoPhaseSeed, seed), index))
return phases
return [{"id": "phase-1", "name": "Todos", "tasks": [normalize_todo_item(cast(TodoSeed, todo)) for todo in todos]}]
def _build_command(self) -> tuple[str, ...]:
if self._command is not None:
return self._command
@@ -487,6 +770,26 @@ class RpcClient:
command.extend(["--model", self._model])
if self._session_dir is not None:
command.extend(["--session-dir", str(self._session_dir)])
if self._thinking is not None:
command.extend(["--thinking", self._thinking])
if self._append_system_prompt is not None:
command.extend(["--append-system-prompt", self._append_system_prompt])
if self._provider_session_id is not None:
command.extend(["--provider-session-id", self._provider_session_id])
if self._tools is not None:
if len(self._tools) == 0:
command.append("--no-tools")
else:
command.extend(["--tools", ",".join(self._tools)])
if self._no_session:
command.append("--no-session")
if self._no_skills:
command.append("--no-skills")
if self._no_rules:
command.append("--no-rules")
emit_no_title = self._no_title if self._no_title is not None else self._rpc_defaults
if emit_no_title:
command.append("--no-title")
command.extend(self._extra_args)
return tuple(command)
@@ -533,8 +836,13 @@ class RpcClient:
continue
notification = parse_notification(payload)
for listener in list(self._notification_listeners):
listener(notification)
if isinstance(notification, ReadyEvent):
self._ready.set()
for listener in list(self._ready_listeners):
listener(notification)
continue
if isinstance(notification, ExtensionUiRequest):
@@ -548,12 +856,19 @@ class RpcClient:
listener(notification)
continue
if getattr(notification, "type", None) != "unknown":
with self._event_condition:
self._events.append(cast(RpcAgentEvent, notification))
self._event_condition.notify_all()
for listener in list(self._event_listeners):
listener(cast(RpcAgentEvent, notification))
if isinstance(notification, UnknownNotification):
for listener in list(self._unknown_notification_listeners):
listener(notification)
continue
event = cast(RpcAgentEvent, notification)
with self._event_condition:
self._events.append(event)
self._event_condition.notify_all()
for listener in list(self._event_listeners):
listener(event)
for listener in list(self._typed_event_listeners.get(event.type, [])):
listener(event)
except json.JSONDecodeError as exc:
self._mark_closed(RpcError(f"Failed to decode RPC output: {exc}"))
except Exception as exc: