mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
fix(v2 managed agents): rewrite opencode normalization for real wire shapes
opencode messages are nested as {info, parts}, not flat. Field naming on
the wire is camelCase with capital-ID suffix (sessionID, messageID,
partID, callID, providerID, modelID). Timestamps are epoch ms ints.
Part types include text/reasoning/tool/step-start/step-finish — only
text parts go into content; only tool parts go into tools. Tool I/O
lives under part.state.input / part.state.output (or .error).
Streaming text deltas come via message.part.delta events with
{messageID, partID, field, delta} — the part type is NOT on the delta
event, so normalize_opencode_event now takes a part_types dict the
caller maintains across the stream. session.idle has no messageID in
real opencode payloads — emit message.completed with message_id=None
and let the adapter enrich it from tracked state. session.error
unwraps the nested error.data.message string.
This commit is contained in:
parent
520c55cf8f
commit
fec3ca1436
1 changed files with 344 additions and 138 deletions
|
|
@ -1,18 +1,34 @@
|
|||
"""Pure normalization helpers for the opencode adapter.
|
||||
|
||||
These functions translate opencode wire shapes into our normalized
|
||||
`MessageRow` and SSE event tuples per contract §7. They have NO side
|
||||
effects, NO HTTP, NO DB calls — pure dict-in / object-out.
|
||||
`MessageRow` and SSE event tuples per contract §7.
|
||||
|
||||
The opencode wire shapes documented in the contract are best-effort. The
|
||||
preflight agent (running in parallel) will document the real payload
|
||||
shapes; defensive `.get()` access is used everywhere so missing keys
|
||||
degrade gracefully instead of raising. Branches that depend on shape
|
||||
details we are not 100% sure of are tagged with
|
||||
`# TODO: verify against preflight findings`.
|
||||
The shapes encoded here are the REAL shapes verified against opencode
|
||||
1.14.41 in the preflight (`.claude/v2_opencode_real_responses.md`). Key
|
||||
deviations from the contract document:
|
||||
|
||||
- opencode messages are nested as ``{info, parts}``, NOT flat.
|
||||
- Field naming on the wire is camelCase with capital-ID suffix
|
||||
(``sessionID``, ``messageID``, ``partID``, ``callID``, ``providerID``,
|
||||
``modelID``). Our normalized output is snake_case.
|
||||
- Timestamps are epoch milliseconds (int), not ISO 8601.
|
||||
- Part types include ``text``, ``reasoning``, ``tool``, ``step-start``,
|
||||
``step-finish``. Only ``text`` parts go into ``content``; only
|
||||
``tool`` parts go into ``tools``. ``reasoning`` / ``step-start`` /
|
||||
``step-finish`` are filtered out for MVP.
|
||||
- Tool I/O lives under ``part.state.input`` / ``part.state.output``
|
||||
(or ``part.state.error``), not directly on ``part``.
|
||||
- Streaming text deltas come via ``message.part.delta`` events with
|
||||
``{messageID, partID, field, delta}`` — the part ``type`` is NOT on
|
||||
the delta event, so the caller must maintain a per-stream
|
||||
``partID -> type`` map sourced from ``message.part.updated`` events
|
||||
(see `route_part_delta`).
|
||||
|
||||
These helpers have NO side effects, NO HTTP, NO DB calls — pure
|
||||
dict-in / object-out.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from litellm.managed_agents.types import MessageRow
|
||||
|
|
@ -27,19 +43,32 @@ def normalize_opencode_message(
|
|||
opencode_msg: Dict[str, Any],
|
||||
our_session_id: str,
|
||||
) -> MessageRow:
|
||||
"""Translate one opencode message dict into a MessageRow.
|
||||
"""Translate one opencode message envelope into a MessageRow.
|
||||
|
||||
Rules (contract §7):
|
||||
- `id` passes through from opencode.
|
||||
- `session_id` is OUR `ses_*` id, NEVER opencode's `oc_sid`.
|
||||
- `content` = concat of `text` parts in order.
|
||||
- `tools` = list of `tool` parts (omit if zero).
|
||||
- `status` = "completed" if `completedAt` set, else "in_progress";
|
||||
"failed" if any part has type="error".
|
||||
- `model` from `modelID` or `model` (whichever opencode emits).
|
||||
- timestamps: parse ISO if string, pass through if already datetime.
|
||||
opencode returns each message as ``{info: {...}, parts: [...]}`` where
|
||||
``info`` carries the message metadata (id, role, model, time, finish)
|
||||
and ``parts`` is the per-part body array.
|
||||
|
||||
Rules (contract §7, adjusted to the real shapes):
|
||||
- ``id`` passes through from ``info.id``.
|
||||
- ``session_id`` is OUR ``ses_*`` id, NEVER opencode's ``sessionID``.
|
||||
- ``content`` = concat of ``text`` parts in order. ``reasoning``,
|
||||
``step-start``, ``step-finish`` are excluded.
|
||||
- ``tools`` = list of normalized tool parts (omit field if zero).
|
||||
- ``model`` = ``"<providerID>/<modelID>"`` if both present; falls
|
||||
back to whichever is present, else None.
|
||||
- ``status`` = ``"completed"`` if ``info.time.completed`` is set,
|
||||
else ``"in_progress"``. ``"failed"`` if any tool part has
|
||||
``state.status == "error"`` OR any part has ``type == "error"``.
|
||||
- timestamps come from ``info.time.created`` / ``info.time.completed``
|
||||
(epoch ms ints).
|
||||
"""
|
||||
info = opencode_msg.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
parts = opencode_msg.get("parts") or []
|
||||
if not isinstance(parts, list):
|
||||
parts = []
|
||||
|
||||
text_chunks: List[str] = []
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
|
|
@ -47,8 +76,6 @@ def normalize_opencode_message(
|
|||
|
||||
for part in parts:
|
||||
if not isinstance(part, dict):
|
||||
# TODO: verify against preflight findings — opencode SHOULD always
|
||||
# emit dict parts, but be defensive about non-dicts in the array.
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
|
|
@ -56,49 +83,94 @@ def normalize_opencode_message(
|
|||
if isinstance(text, str):
|
||||
text_chunks.append(text)
|
||||
elif ptype == "tool":
|
||||
tool_calls.append(
|
||||
{
|
||||
"name": part.get("name"),
|
||||
"input": part.get("input"),
|
||||
"output": part.get("output"),
|
||||
}
|
||||
)
|
||||
tool_calls.append(_normalize_tool_part(part))
|
||||
state = part.get("state") or {}
|
||||
if isinstance(state, dict) and state.get("status") == "error":
|
||||
has_error_part = True
|
||||
elif ptype == "error":
|
||||
# Defensive: opencode does not currently emit a top-level "error"
|
||||
# part type (errors live under tool ``state.error`` or as
|
||||
# ``session.error`` events), but we treat one as a failure if
|
||||
# it ever appears.
|
||||
has_error_part = True
|
||||
# All other part types (e.g. permission, lsp diagnostics) are ignored.
|
||||
# TODO: verify against preflight findings.
|
||||
# All other part types (``reasoning``, ``step-start``, ``step-finish``,
|
||||
# plus any future additions) are deliberately dropped from
|
||||
# ``content``/``tools`` for MVP.
|
||||
|
||||
content = "".join(text_chunks)
|
||||
tools: Optional[List[Dict[str, Any]]] = tool_calls if tool_calls else None
|
||||
|
||||
completed_at_raw = opencode_msg.get("completedAt")
|
||||
time_obj = info.get("time") or {}
|
||||
if not isinstance(time_obj, dict):
|
||||
time_obj = {}
|
||||
completed_at_raw = time_obj.get("completed")
|
||||
created_at_raw = time_obj.get("created")
|
||||
|
||||
if has_error_part:
|
||||
status = "failed"
|
||||
elif completed_at_raw:
|
||||
elif completed_at_raw is not None:
|
||||
status = "completed"
|
||||
else:
|
||||
status = "in_progress"
|
||||
|
||||
# opencode field name: prefer `modelID` (camelCase, opencode convention),
|
||||
# fall back to `model` for forward-compat.
|
||||
# TODO: verify against preflight findings.
|
||||
model = opencode_msg.get("modelID") or opencode_msg.get("model")
|
||||
|
||||
created_at_raw = opencode_msg.get("createdAt")
|
||||
model = _format_model(info.get("providerID"), info.get("modelID"))
|
||||
|
||||
return MessageRow(
|
||||
id=opencode_msg.get("id", ""),
|
||||
id=info.get("id", ""),
|
||||
session_id=our_session_id,
|
||||
role=opencode_msg.get("role", "assistant"),
|
||||
role=info.get("role", "assistant"),
|
||||
content=content,
|
||||
model=model,
|
||||
tools=tools,
|
||||
status=status, # type: ignore[arg-type]
|
||||
created_at=_parse_ts(created_at_raw) or datetime.utcnow(),
|
||||
created_at=_parse_ts(created_at_raw) or datetime.now(timezone.utc),
|
||||
completed_at=_parse_ts(completed_at_raw),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_tool_part(part: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract ``{name, input, output}`` from an opencode tool part.
|
||||
|
||||
Real opencode shape:
|
||||
{
|
||||
"type": "tool",
|
||||
"tool": "read", # tool name (top-level)
|
||||
"callID": "call_...", # opencode call id
|
||||
"state": {
|
||||
"status": "completed", # pending|running|completed|error
|
||||
"input": {...},
|
||||
"output": "...", # OR
|
||||
"error": "...", # for status=="error"
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
state = part.get("state") or {}
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
output: Any = state.get("output")
|
||||
if output is None and state.get("status") == "error":
|
||||
output = state.get("error")
|
||||
return {
|
||||
"name": part.get("tool"),
|
||||
"input": state.get("input"),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def _format_model(provider_id: Any, model_id: Any) -> Optional[str]:
|
||||
"""Combine opencode's ``providerID`` + ``modelID`` into our model string.
|
||||
|
||||
Returns ``"<provider>/<model>"`` when both are present, else whichever
|
||||
is present, else None.
|
||||
"""
|
||||
p = provider_id if isinstance(provider_id, str) and provider_id else None
|
||||
m = model_id if isinstance(model_id, str) and model_id else None
|
||||
if p and m:
|
||||
return f"{p}/{m}"
|
||||
return p or m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -106,120 +178,228 @@ def normalize_opencode_message(
|
|||
|
||||
def normalize_opencode_event(
|
||||
raw_event: Dict[str, Any],
|
||||
part_types: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""Translate one opencode SSE event dict into our (type, data) tuple.
|
||||
|
||||
Returns None for events we drop (per contract §7 — `permission.updated`,
|
||||
`lsp.client.diagnostics`, etc.). The caller is responsible for filtering
|
||||
by `sessionID` BEFORE invoking this function — this helper only does
|
||||
type-and-shape mapping.
|
||||
The ``part_types`` argument is a mutable map of ``partID -> part_type``
|
||||
maintained by the caller across the lifetime of one stream. It's
|
||||
required to route ``message.part.delta`` events because those events
|
||||
do not carry the part type — only the ``partID``.
|
||||
|
||||
opencode events come in shapes like:
|
||||
{"type": "message.updated", "properties": {...}}
|
||||
{"type": "message.part.updated", "properties": {"part": {...}, ...}}
|
||||
|
||||
We access `properties` defensively because the exact wrapper shape
|
||||
differs across opencode versions.
|
||||
Returns None for events we drop (heartbeats, unknown types,
|
||||
reasoning deltas, etc.).
|
||||
"""
|
||||
if not isinstance(raw_event, dict):
|
||||
return None
|
||||
|
||||
event_type = raw_event.get("type")
|
||||
if not isinstance(event_type, str):
|
||||
if not isinstance(event_type, str) or not event_type:
|
||||
return None
|
||||
|
||||
# opencode wraps payload data in `properties` per its event-bus convention.
|
||||
# TODO: verify against preflight findings — some versions may inline the
|
||||
# payload at the top level instead.
|
||||
props = raw_event.get("properties") or {}
|
||||
if not isinstance(props, dict):
|
||||
props = {}
|
||||
|
||||
if event_type == "message.updated":
|
||||
message = props.get("info") or props.get("message") or {}
|
||||
message_id = (
|
||||
message.get("id") if isinstance(message, dict) else None
|
||||
) or props.get("messageID")
|
||||
role = (
|
||||
message.get("role") if isinstance(message, dict) else None
|
||||
) or props.get("role")
|
||||
return (
|
||||
"message.started",
|
||||
{"message_id": message_id, "role": role},
|
||||
)
|
||||
return _normalize_message_updated(props)
|
||||
|
||||
if event_type == "message.part.updated":
|
||||
part = props.get("part") or {}
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
message_id = part.get("messageID") or props.get("messageID")
|
||||
ptype = part.get("type")
|
||||
return _normalize_message_part_updated(props, part_types)
|
||||
|
||||
if ptype == "text":
|
||||
# TODO: verify against preflight findings — opencode may emit
|
||||
# cumulative text or only the new delta. We pass through what
|
||||
# the part contains; the SDK handles either case.
|
||||
delta = part.get("text") or part.get("delta") or ""
|
||||
return (
|
||||
"message.text.delta",
|
||||
{"message_id": message_id, "delta": delta},
|
||||
)
|
||||
if event_type == "message.part.delta":
|
||||
return _normalize_message_part_delta(props, part_types)
|
||||
|
||||
if ptype == "tool":
|
||||
tool_name = part.get("name")
|
||||
output = part.get("output")
|
||||
if output is not None:
|
||||
# Tool finished (has output).
|
||||
return (
|
||||
"message.tool.completed",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"tool": tool_name,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
# Tool started (no output yet).
|
||||
# TODO: verify against preflight findings — some opencode
|
||||
# versions use a `state`/`status` field on the tool part to
|
||||
# distinguish start from completion instead of presence of
|
||||
# `output`. We use presence-of-output as the default.
|
||||
if event_type == "session.idle":
|
||||
return _normalize_session_idle(props)
|
||||
|
||||
if event_type == "session.error":
|
||||
return _normalize_session_error(props)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_message_updated(
|
||||
props: Dict[str, Any],
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""``message.updated`` — emit ``message.started`` (new msg) or
|
||||
``message.completed`` (existing msg with ``time.completed`` set)."""
|
||||
info = props.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
|
||||
message_id = info.get("id")
|
||||
role = info.get("role")
|
||||
time_obj = info.get("time") or {}
|
||||
if not isinstance(time_obj, dict):
|
||||
time_obj = {}
|
||||
completed_ms = time_obj.get("completed")
|
||||
|
||||
if completed_ms is not None and role == "assistant":
|
||||
# The assistant turn has finished. opencode emits this just before
|
||||
# ``session.idle`` and it carries enough info to attach to a
|
||||
# specific message. We prefer this over ``session.idle`` because
|
||||
# it's per-message rather than per-session.
|
||||
return (
|
||||
"message.completed",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"completed_at": _ms_to_iso_str(completed_ms),
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
"message.started",
|
||||
{"message_id": message_id, "role": role},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_message_part_updated(
|
||||
props: Dict[str, Any],
|
||||
part_types: Optional[Dict[str, str]],
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""``message.part.updated`` — covers part lifecycle (creation, status
|
||||
transitions for tools, finalization). Streaming text deltas do NOT come
|
||||
through this event — see ``_normalize_message_part_delta``.
|
||||
"""
|
||||
part = props.get("part") or {}
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
|
||||
part_id = part.get("id")
|
||||
ptype = part.get("type")
|
||||
message_id = part.get("messageID")
|
||||
|
||||
# Track the part's type for later ``message.part.delta`` routing.
|
||||
if part_types is not None and isinstance(part_id, str) and isinstance(ptype, str):
|
||||
part_types[part_id] = ptype
|
||||
|
||||
if ptype == "tool":
|
||||
state = part.get("state") or {}
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
status = state.get("status")
|
||||
tool_name = part.get("tool")
|
||||
|
||||
if status == "running":
|
||||
return (
|
||||
"message.tool.started",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"tool": tool_name,
|
||||
"input": part.get("input"),
|
||||
"input": state.get("input"),
|
||||
},
|
||||
)
|
||||
|
||||
# Other part types (e.g. step-start, file) are dropped.
|
||||
if status == "completed":
|
||||
return (
|
||||
"message.tool.completed",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"tool": tool_name,
|
||||
"output": state.get("output"),
|
||||
},
|
||||
)
|
||||
if status == "error":
|
||||
return (
|
||||
"message.tool.completed",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"tool": tool_name,
|
||||
"output": state.get("error"),
|
||||
"error": True,
|
||||
},
|
||||
)
|
||||
# ``pending`` or unknown statuses: drop. The ``running`` transition
|
||||
# carries the populated ``input`` we want.
|
||||
return None
|
||||
|
||||
if event_type == "session.idle":
|
||||
# Turn done — emit `message.completed`. opencode's idle payload may
|
||||
# not always include the message id/content directly; best-effort.
|
||||
# TODO: verify against preflight findings.
|
||||
message_id = props.get("messageID") or props.get("message_id")
|
||||
return (
|
||||
"message.completed",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"content": props.get("content"),
|
||||
"completed_at": props.get("completedAt") or props.get("completed_at"),
|
||||
},
|
||||
)
|
||||
|
||||
if event_type == "session.error":
|
||||
message_id = props.get("messageID") or props.get("message_id")
|
||||
error = props.get("error") or props.get("message")
|
||||
return (
|
||||
"error",
|
||||
{"message_id": message_id, "error": error},
|
||||
)
|
||||
|
||||
# Text/reasoning/step-* part lifecycle events don't translate to our
|
||||
# event surface — only their delta chunks do. We've already updated
|
||||
# ``part_types`` above so subsequent deltas can be routed.
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_message_part_delta(
|
||||
props: Dict[str, Any],
|
||||
part_types: Optional[Dict[str, str]],
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""``message.part.delta`` — incremental chunk for a streaming part.
|
||||
|
||||
Real shape: ``{sessionID, messageID, partID, field, delta}``. The part
|
||||
``type`` is NOT on this event. We look it up in ``part_types`` (which
|
||||
must have been populated by a prior ``message.part.updated`` event for
|
||||
this ``partID``).
|
||||
"""
|
||||
part_id = props.get("partID")
|
||||
field = props.get("field")
|
||||
delta = props.get("delta")
|
||||
message_id = props.get("messageID")
|
||||
|
||||
if not isinstance(part_id, str) or not isinstance(delta, str):
|
||||
return None
|
||||
|
||||
ptype = (part_types or {}).get(part_id)
|
||||
|
||||
if field == "text" and ptype == "text":
|
||||
return (
|
||||
"message.text.delta",
|
||||
{"message_id": message_id, "delta": delta},
|
||||
)
|
||||
|
||||
# Reasoning deltas are intentionally dropped for MVP. Other field
|
||||
# values (e.g. tool ``raw``) are also dropped.
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_session_idle(
|
||||
props: Dict[str, Any],
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""``session.idle`` — turn done.
|
||||
|
||||
The real opencode payload is ``{sessionID}`` only — it does NOT carry
|
||||
a ``messageID`` or ``content``. The adapter is expected to track the
|
||||
"current assistant message id" from prior ``message.updated`` events
|
||||
and attach it before yielding to the SSE consumer (see
|
||||
``OpencodeAdapter.stream_events``).
|
||||
|
||||
We still emit a ``message.completed`` here as a safety net for cases
|
||||
where the per-message ``message.updated`` with ``time.completed`` was
|
||||
missed. The adapter overrides ``message_id`` if it has tracked one.
|
||||
"""
|
||||
return (
|
||||
"message.completed",
|
||||
{
|
||||
"message_id": props.get("messageID"),
|
||||
"completed_at": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_session_error(
|
||||
props: Dict[str, Any],
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""``session.error`` — opencode runtime error.
|
||||
|
||||
Real shape: ``{sessionID, error: {name, data: {message}}}``. We unwrap
|
||||
to the human-readable message string when possible.
|
||||
"""
|
||||
error_obj = props.get("error")
|
||||
error_message: Any = error_obj
|
||||
if isinstance(error_obj, dict):
|
||||
data = error_obj.get("data")
|
||||
if isinstance(data, dict) and isinstance(data.get("message"), str):
|
||||
error_message = data["message"]
|
||||
elif isinstance(error_obj.get("message"), str):
|
||||
error_message = error_obj["message"]
|
||||
return (
|
||||
"error",
|
||||
{
|
||||
"message_id": props.get("messageID"),
|
||||
"error": error_message,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — opencode session id matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -229,12 +409,15 @@ def event_matches_session(
|
|||
raw_event: Dict[str, Any],
|
||||
opencode_session_id: str,
|
||||
) -> bool:
|
||||
"""Return True if `raw_event` references `opencode_session_id`.
|
||||
"""Return True if ``raw_event`` references ``opencode_session_id``.
|
||||
|
||||
opencode events include the session id under `sessionID` at the top
|
||||
level OR nested under `properties.sessionID`. We check both. Events
|
||||
without a session id (rare — typically global health/heartbeat) are
|
||||
treated as non-matching.
|
||||
opencode events carry the session id as ``properties.sessionID``
|
||||
(camelCase) — and sometimes also at the top level. We check both, plus
|
||||
the nested ``info.sessionID`` for ``message.updated`` events.
|
||||
|
||||
Events without any session id are treated as non-matching, which means
|
||||
global heartbeats (``server.heartbeat``, ``server.connected``) are
|
||||
correctly filtered out.
|
||||
"""
|
||||
if not isinstance(raw_event, dict):
|
||||
return False
|
||||
|
|
@ -244,11 +427,12 @@ def event_matches_session(
|
|||
if isinstance(props, dict):
|
||||
if props.get("sessionID") == opencode_session_id:
|
||||
return True
|
||||
# The session id may also live nested on a sub-object (e.g. on the
|
||||
# `info` message for `message.updated`).
|
||||
info = props.get("info") or props.get("message") or {}
|
||||
info = props.get("info")
|
||||
if isinstance(info, dict) and info.get("sessionID") == opencode_session_id:
|
||||
return True
|
||||
part = props.get("part")
|
||||
if isinstance(part, dict) and part.get("sessionID") == opencode_session_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -258,27 +442,49 @@ def event_matches_session(
|
|||
|
||||
|
||||
def _parse_ts(value: Any) -> Optional[datetime]:
|
||||
"""Parse an opencode timestamp into a datetime, or None.
|
||||
"""Parse an opencode timestamp into a tz-aware UTC datetime, or None.
|
||||
|
||||
opencode timestamps are sometimes ISO 8601 strings, sometimes
|
||||
ms-since-epoch ints (per its TS codebase). Be defensive about both.
|
||||
opencode timestamps on the wire are epoch milliseconds (int). Strings
|
||||
are accepted defensively (e.g. for tests that still use ISO strings),
|
||||
and existing datetime values pass through.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
# ``True``/``False`` are technically ``int`` subclasses — guard explicitly.
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
# opencode timestamps are ms-since-epoch in its TS code.
|
||||
try:
|
||||
return datetime.utcfromtimestamp(value / 1000.0)
|
||||
return datetime.fromtimestamp(value / 1000.0, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
# Accept "2026-05-07T15:04:05.123Z" by swapping trailing Z for +00:00,
|
||||
# which fromisoformat understands on 3.10+.
|
||||
s = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ms_to_iso(ms: Any) -> Optional[datetime]:
|
||||
"""Convert opencode ``epoch ms`` into a tz-aware UTC datetime.
|
||||
|
||||
Thin wrapper around ``_parse_ts`` for callers that want the explicit
|
||||
"this is opencode-style ms" intent.
|
||||
"""
|
||||
return _parse_ts(ms)
|
||||
|
||||
|
||||
def _ms_to_iso_str(ms: Any) -> Optional[str]:
|
||||
"""Convert opencode ``epoch ms`` into an ISO 8601 UTC string.
|
||||
|
||||
Used for SSE event payloads where the wire format is JSON (string),
|
||||
not a Python datetime.
|
||||
"""
|
||||
dt = _parse_ts(ms)
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue