mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
test(v2 managed agents): update normalization tests for real opencode wire shapes
Update fixtures to use the {info, parts} envelope, epoch-ms timestamps,
camelCase wire field names (providerID/modelID), and the message.part.delta
event shape. Add coverage for:
- filtering step-start/step-finish/reasoning parts out of content
- tool state.status branches (running/completed/error/pending)
- the part_types map populated by message.part.updated
- message.part.delta routing via part_types lookup (text vs reasoning,
unknown partID drops)
- message.updated -> message.completed when info.time.completed is set
- session.error unwrapping the nested error.data.message string
- server.heartbeat / session.created drop to None
This commit is contained in:
parent
a00695114f
commit
56b6da5a2f
1 changed files with 435 additions and 136 deletions
|
|
@ -1,14 +1,23 @@
|
|||
"""Unit tests for the opencode normalization helpers.
|
||||
|
||||
Covers `normalize_opencode_message` and `normalize_opencode_event` per the
|
||||
v2 contract §7 (`.claude/v2_api_contract.md`):
|
||||
Covers `normalize_opencode_message` and `normalize_opencode_event` per
|
||||
the v2 contract §7 (`.claude/v2_api_contract.md`) and against the REAL
|
||||
opencode 1.14.41 wire shapes documented in
|
||||
`.claude/v2_opencode_real_responses.md`.
|
||||
|
||||
- Message: text-only / text+tool / completedAt absent/present / error part
|
||||
/ our_session_id preserved / id pass-through.
|
||||
- Event: message.updated -> message.started; message.part.updated text ->
|
||||
message.text.delta; message.part.updated tool start/finish ->
|
||||
message.tool.started/completed; session.idle -> message.completed;
|
||||
session.error -> error; unknown types -> None.
|
||||
Coverage:
|
||||
- Message: nested ``{info, parts}`` envelope; epoch-ms timestamps;
|
||||
camelCase ``providerID``/``modelID`` combined into our ``model``;
|
||||
text-only / mixed text+tool / completed absent-vs-present /
|
||||
tool error → failed; opencode session id never leaks; id passthrough.
|
||||
- Event: ``message.updated`` → ``message.started`` (new) /
|
||||
``message.completed`` (with ``info.time.completed``);
|
||||
``message.part.updated`` populates the ``part_types`` map and emits
|
||||
``message.tool.started`` / ``.completed`` based on ``state.status``;
|
||||
``message.part.delta`` routes via ``part_types`` to
|
||||
``message.text.delta`` (or drops for reasoning); ``session.idle``
|
||||
emits a ``message.completed``; ``session.error`` extracts the nested
|
||||
``error.data.message``; unknown types return None.
|
||||
|
||||
These tests are pure: no HTTP, no DB, no async. They exercise dict-in /
|
||||
object-out behavior of the normalization layer only.
|
||||
|
|
@ -19,34 +28,42 @@ from typing import Any, Dict
|
|||
|
||||
import pytest
|
||||
|
||||
# The normalization module is being built in parallel by the adapter agent.
|
||||
# Skip cleanly if it isn't here yet so the rest of the test suite still runs.
|
||||
normalization = pytest.importorskip(
|
||||
"litellm.managed_agents.adapters.normalization"
|
||||
)
|
||||
normalization = pytest.importorskip("litellm.managed_agents.adapters.normalization")
|
||||
|
||||
normalize_opencode_message = normalization.normalize_opencode_message
|
||||
normalize_opencode_event = normalization.normalize_opencode_event
|
||||
|
||||
|
||||
# Real opencode timestamp from the preflight (epoch ms).
|
||||
_TS_CREATED_MS = 1778172682972
|
||||
_TS_COMPLETED_MS = 1778172689905
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_opencode_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeOpencodeMessage:
|
||||
"""Message normalization (opencode -> our MessageRow)."""
|
||||
"""Message normalization (opencode {info, parts} -> our MessageRow)."""
|
||||
|
||||
def test_text_only_message_joins_content_and_omits_tools(self) -> None:
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world."},
|
||||
],
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
|
@ -54,22 +71,62 @@ class TestNormalizeOpencodeMessage:
|
|||
assert row.content == "Hello world."
|
||||
# tools field should be omitted (None) when there are no tool parts.
|
||||
assert row.tools is None
|
||||
assert row.model == "anthropic/claude-3-haiku"
|
||||
|
||||
def test_filters_out_step_and_reasoning_parts(self) -> None:
|
||||
"""Only ``text`` parts make it into ``content``. ``reasoning``,
|
||||
``step-start``, ``step-finish`` are excluded for MVP."""
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "opencode",
|
||||
"modelID": "minimax-m2.5-free",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [
|
||||
{"type": "step-start", "snapshot": "..."},
|
||||
{"type": "reasoning", "text": "thinking out loud..."},
|
||||
{"type": "text", "text": "\n\n4"},
|
||||
{"type": "step-finish", "reason": "stop"},
|
||||
],
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
||||
assert row.content == "\n\n4"
|
||||
assert row.tools is None
|
||||
|
||||
def test_mixed_text_and_tool_parts_extracts_both(self) -> None:
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [
|
||||
{"type": "text", "text": "I'll read the file..."},
|
||||
{
|
||||
"type": "tool",
|
||||
"name": "read",
|
||||
"input": {"path": "src/auth.py"},
|
||||
"output": "...file contents...",
|
||||
"tool": "read",
|
||||
"callID": "call_xxx",
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"filePath": "src/auth.py"},
|
||||
"output": "...file contents...",
|
||||
},
|
||||
},
|
||||
],
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
|
@ -78,18 +135,22 @@ class TestNormalizeOpencodeMessage:
|
|||
assert row.tools == [
|
||||
{
|
||||
"name": "read",
|
||||
"input": {"path": "src/auth.py"},
|
||||
"input": {"filePath": "src/auth.py"},
|
||||
"output": "...file contents...",
|
||||
}
|
||||
]
|
||||
|
||||
def test_completed_at_null_yields_in_progress_status(self) -> None:
|
||||
def test_completed_absent_yields_in_progress_status(self) -> None:
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {"created": _TS_CREATED_MS},
|
||||
},
|
||||
"parts": [{"type": "text", "text": "thinking..."}],
|
||||
"completedAt": None,
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
|
@ -97,13 +158,20 @@ class TestNormalizeOpencodeMessage:
|
|||
assert row.status == "in_progress"
|
||||
assert row.completed_at is None
|
||||
|
||||
def test_completed_at_set_yields_completed_status(self) -> None:
|
||||
def test_completed_set_yields_completed_status(self) -> None:
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [{"type": "text", "text": "Done."}],
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
|
@ -112,54 +180,110 @@ class TestNormalizeOpencodeMessage:
|
|||
assert row.completed_at is not None
|
||||
assert isinstance(row.completed_at, datetime)
|
||||
|
||||
def test_error_part_yields_failed_status(self) -> None:
|
||||
def test_tool_state_error_yields_failed_status(self) -> None:
|
||||
"""A tool part with ``state.status == "error"`` overrides ``completed``."""
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [
|
||||
{"type": "text", "text": "tried..."},
|
||||
{"type": "error", "error": "boom"},
|
||||
{
|
||||
"type": "tool",
|
||||
"tool": "read",
|
||||
"state": {
|
||||
"status": "error",
|
||||
"input": {"filePath": "/etc/hostname"},
|
||||
"error": "File not found",
|
||||
},
|
||||
},
|
||||
],
|
||||
# Even with completedAt set, an error part should win.
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
||||
assert row.status == "failed"
|
||||
assert row.tools == [
|
||||
{
|
||||
"name": "read",
|
||||
"input": {"filePath": "/etc/hostname"},
|
||||
"output": "File not found",
|
||||
}
|
||||
]
|
||||
|
||||
def test_session_id_is_our_session_not_opencode_session(self) -> None:
|
||||
"""The opencode `sessionID` MUST NOT leak into our MessageRow."""
|
||||
"""The opencode ``sessionID`` MUST NOT leak into our MessageRow."""
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "oc_sid_NEVER_LEAK",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_abc",
|
||||
"sessionID": "ses_oc_NEVER_LEAK",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [{"type": "text", "text": "hi"}],
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_ours")
|
||||
|
||||
assert row.session_id == "ses_ours"
|
||||
# Defensive: the opencode session id must not appear in serialization.
|
||||
dumped = row.model_dump()
|
||||
assert dumped["session_id"] == "ses_ours"
|
||||
assert "oc_sid_NEVER_LEAK" not in str(dumped.get("session_id", ""))
|
||||
assert "ses_oc_NEVER_LEAK" not in str(dumped.get("session_id", ""))
|
||||
|
||||
def test_id_is_passed_through_from_opencode(self) -> None:
|
||||
def test_id_is_passed_through_from_info(self) -> None:
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"id": "msg_oc_specific_id_123",
|
||||
"sessionID": "oc_sid_xxx",
|
||||
"role": "assistant",
|
||||
"info": {
|
||||
"id": "msg_oc_specific_id_123",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"providerID": "anthropic",
|
||||
"modelID": "claude-3-haiku",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
"parts": [{"type": "text", "text": "hi"}],
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
||||
assert row.id == "msg_oc_specific_id_123"
|
||||
|
||||
def test_user_message_with_no_provider_or_model_yields_none_model(
|
||||
self,
|
||||
) -> None:
|
||||
"""User messages on opencode have ``info.model`` (object) but no
|
||||
flat ``providerID``/``modelID``; the normalizer falls back to
|
||||
None when neither is present."""
|
||||
oc_msg: Dict[str, Any] = {
|
||||
"info": {
|
||||
"id": "msg_user_1",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "user",
|
||||
"time": {"created": _TS_CREATED_MS},
|
||||
},
|
||||
"parts": [{"type": "text", "text": "hi"}],
|
||||
}
|
||||
|
||||
row = normalize_opencode_message(oc_msg, our_session_id="ses_test")
|
||||
|
||||
assert row.role == "user"
|
||||
assert row.model is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_opencode_event
|
||||
|
|
@ -169,11 +293,21 @@ class TestNormalizeOpencodeMessage:
|
|||
class TestNormalizeOpencodeEvent:
|
||||
"""SSE event normalization (opencode bus -> our event tuples)."""
|
||||
|
||||
def test_message_updated_yields_message_started(self) -> None:
|
||||
def test_message_updated_for_new_assistant_yields_message_started(
|
||||
self,
|
||||
) -> None:
|
||||
"""A ``message.updated`` event without ``time.completed`` =
|
||||
in-flight message; emit ``message.started``."""
|
||||
raw = {
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"info": {"id": "msg_a4b5c6", "role": "assistant"},
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"info": {
|
||||
"id": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"time": {"created": _TS_CREATED_MS},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -184,99 +318,263 @@ class TestNormalizeOpencodeEvent:
|
|||
assert event_type == "message.started"
|
||||
assert data == {"message_id": "msg_a4b5c6", "role": "assistant"}
|
||||
|
||||
def test_message_part_updated_text_yields_text_delta(self) -> None:
|
||||
def test_message_updated_with_completed_yields_message_completed(self) -> None:
|
||||
"""When ``info.time.completed`` is set on an assistant message,
|
||||
we emit ``message.completed`` with the per-message id."""
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"type": "text",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"text": "Hello",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"info": {
|
||||
"id": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"role": "assistant",
|
||||
"time": {
|
||||
"created": _TS_CREATED_MS,
|
||||
"completed": _TS_COMPLETED_MS,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.text.delta"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["delta"] == "Hello"
|
||||
|
||||
def test_message_part_updated_tool_start_yields_tool_started(self) -> None:
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"name": "read",
|
||||
"input": {"path": "src/auth.py"},
|
||||
# No `output` key yet — tool is in flight.
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.tool.started"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["tool"] == "read"
|
||||
assert data["input"] == {"path": "src/auth.py"}
|
||||
|
||||
def test_message_part_updated_tool_finish_yields_tool_completed(
|
||||
self,
|
||||
) -> None:
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"name": "read",
|
||||
"input": {"path": "src/auth.py"},
|
||||
"output": "file contents here",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.tool.completed"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["tool"] == "read"
|
||||
assert data["output"] == "file contents here"
|
||||
|
||||
def test_session_idle_yields_message_completed(self) -> None:
|
||||
raw = {
|
||||
"type": "session.idle",
|
||||
"properties": {
|
||||
"messageID": "msg_a4b5c6",
|
||||
"content": "I'm done.",
|
||||
"completedAt": "2026-05-07T15:04:05.123Z",
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.completed"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["content"] == "I'm done."
|
||||
assert data["completed_at"] == "2026-05-07T15:04:05.123Z"
|
||||
assert data["completed_at"] is not None
|
||||
# ISO 8601 UTC string with 'Z' suffix.
|
||||
assert data["completed_at"].endswith("Z")
|
||||
|
||||
def test_session_error_yields_error(self) -> None:
|
||||
def test_message_part_updated_text_populates_part_types_map(self) -> None:
|
||||
"""A text ``message.part.updated`` doesn't emit a normalized
|
||||
event itself — but it MUST register the ``partID -> "text"``
|
||||
mapping for subsequent deltas."""
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"part": {
|
||||
"id": "prt_text_1",
|
||||
"type": "text",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"text": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
part_types: Dict[str, str] = {}
|
||||
out = normalize_opencode_event(raw, part_types=part_types)
|
||||
|
||||
assert out is None
|
||||
assert part_types == {"prt_text_1": "text"}
|
||||
|
||||
def test_message_part_delta_routes_text_to_text_delta(self) -> None:
|
||||
"""``message.part.delta`` looks up the partID in ``part_types``
|
||||
and emits ``message.text.delta`` only for text parts."""
|
||||
part_types = {"prt_text_1": "text"}
|
||||
raw = {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"partID": "prt_text_1",
|
||||
"field": "text",
|
||||
"delta": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types=part_types)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.text.delta"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["delta"] == "Hello"
|
||||
|
||||
def test_message_part_delta_drops_reasoning_chunks(self) -> None:
|
||||
"""Reasoning deltas are intentionally dropped for MVP."""
|
||||
part_types = {"prt_reasoning_1": "reasoning"}
|
||||
raw = {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"partID": "prt_reasoning_1",
|
||||
"field": "text",
|
||||
"delta": "the user is asking...",
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types=part_types)
|
||||
|
||||
assert out is None
|
||||
|
||||
def test_message_part_delta_with_unknown_partid_drops(self) -> None:
|
||||
"""A delta whose partID was never seen in a prior
|
||||
``message.part.updated`` cannot be routed and must be dropped
|
||||
rather than guessed."""
|
||||
raw = {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"partID": "prt_unknown",
|
||||
"field": "text",
|
||||
"delta": "??",
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types={})
|
||||
|
||||
assert out is None
|
||||
|
||||
def test_message_part_updated_tool_running_yields_tool_started(self) -> None:
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"tool": "read",
|
||||
"callID": "call_xxx",
|
||||
"state": {
|
||||
"status": "running",
|
||||
"input": {"filePath": "src/auth.py"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
part_types: Dict[str, str] = {}
|
||||
out = normalize_opencode_event(raw, part_types=part_types)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.tool.started"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["tool"] == "read"
|
||||
assert data["input"] == {"filePath": "src/auth.py"}
|
||||
assert part_types == {"prt_tool_1": "tool"}
|
||||
|
||||
def test_message_part_updated_tool_completed_yields_tool_completed(
|
||||
self,
|
||||
) -> None:
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"tool": "read",
|
||||
"callID": "call_xxx",
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"filePath": "src/auth.py"},
|
||||
"output": "file contents here",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types={})
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.tool.completed"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["tool"] == "read"
|
||||
assert data["output"] == "file contents here"
|
||||
assert "error" not in data
|
||||
|
||||
def test_message_part_updated_tool_error_yields_tool_completed_with_error_flag(
|
||||
self,
|
||||
) -> None:
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"tool": "read",
|
||||
"callID": "call_xxx",
|
||||
"state": {
|
||||
"status": "error",
|
||||
"input": {"filePath": "/etc/hostname"},
|
||||
"error": "File not found: /etc/hostname",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types={})
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.tool.completed"
|
||||
assert data["error"] is True
|
||||
assert data["output"] == "File not found: /etc/hostname"
|
||||
|
||||
def test_message_part_updated_tool_pending_drops(self) -> None:
|
||||
"""``pending`` is the brief state before opencode populates the
|
||||
tool input; we wait for ``running`` (which has the input) before
|
||||
emitting our ``tool.started`` event."""
|
||||
raw = {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"type": "tool",
|
||||
"messageID": "msg_a4b5c6",
|
||||
"tool": "read",
|
||||
"state": {"status": "pending", "input": {}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw, part_types={})
|
||||
|
||||
assert out is None
|
||||
|
||||
def test_session_idle_yields_message_completed(self) -> None:
|
||||
"""``session.idle`` payload only carries ``sessionID`` — the
|
||||
adapter is expected to enrich ``message_id`` from tracked state.
|
||||
Bare normalizer output has ``message_id == None``."""
|
||||
raw = {
|
||||
"type": "session.idle",
|
||||
"properties": {"sessionID": "ses_oc_xxx"},
|
||||
}
|
||||
|
||||
out = normalize_opencode_event(raw)
|
||||
|
||||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "message.completed"
|
||||
assert data["message_id"] is None
|
||||
assert data["completed_at"] is None
|
||||
|
||||
def test_session_error_extracts_nested_message(self) -> None:
|
||||
"""Real opencode shape: ``properties.error.data.message``."""
|
||||
raw = {
|
||||
"type": "session.error",
|
||||
"properties": {
|
||||
"messageID": "msg_a4b5c6",
|
||||
"error": "model unavailable",
|
||||
"sessionID": "ses_oc_xxx",
|
||||
"error": {
|
||||
"name": "UnknownError",
|
||||
"data": {"message": "Model not found: anthropic/claude-3-haiku."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -285,8 +583,7 @@ class TestNormalizeOpencodeEvent:
|
|||
assert out is not None
|
||||
event_type, data = out
|
||||
assert event_type == "error"
|
||||
assert data["message_id"] == "msg_a4b5c6"
|
||||
assert data["error"] == "model unavailable"
|
||||
assert data["error"] == "Model not found: anthropic/claude-3-haiku."
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
|
|
@ -295,6 +592,8 @@ class TestNormalizeOpencodeEvent:
|
|||
{"type": "lsp.client.diagnostics", "properties": {}},
|
||||
{"type": "totally.unknown.event", "properties": {}},
|
||||
{"type": "", "properties": {}},
|
||||
{"type": "server.heartbeat", "properties": {}},
|
||||
{"type": "session.created", "properties": {}},
|
||||
],
|
||||
)
|
||||
def test_unknown_event_types_return_none(self, raw: Dict[str, Any]) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue