1003 lines
37 KiB
Python
1003 lines
37 KiB
Python
"""Unit tests for the sessions-API REPL adapter event translator.
|
|
|
|
Three CUJs caught these regressions during manual testing of
|
|
``omnigent run`` in sessions mode:
|
|
|
|
1. ``ResponseCreated`` was imported from the public ``omnigent_client``
|
|
re-export, which doesn't include it. ImportError on first turn.
|
|
2. The adapter passed server-shape events
|
|
(:class:`omnigent.server.schemas.OutputTextDeltaEvent`) through
|
|
without translating to the SDK-shape dataclasses in
|
|
:mod:`omnigent_client._events`. Net: spinner spun, server-side
|
|
LLM call completed, no assistant text reached the TUI.
|
|
3. The translator initially used wrong class names from
|
|
``omnigent.server.schemas`` (``ResponseTextDeltaEvent`` vs the
|
|
actual ``OutputTextDeltaEvent``); silent ImportError on the local
|
|
import inside the translator.
|
|
|
|
This file pins each translation case so all three classes of bug
|
|
surface at import time or in a sub-second unit test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from omnigent_client._events import (
|
|
ReasoningDelta,
|
|
ReasoningStarted,
|
|
ReasoningSummaryDelta,
|
|
ResponseCancelled,
|
|
ResponseCompleted,
|
|
ResponseCreated,
|
|
ResponseFailed,
|
|
ResponseIncomplete,
|
|
ResponseInProgress,
|
|
ResponseQueued,
|
|
TextDelta,
|
|
)
|
|
|
|
from omnigent.repl._repl import (
|
|
_plan_output_item_render,
|
|
_server_event_to_sdk_event,
|
|
_TurnProseTracker,
|
|
)
|
|
from omnigent.server.schemas import (
|
|
CancelledEvent,
|
|
CompletedEvent,
|
|
CreatedEvent,
|
|
FailedEvent,
|
|
IncompleteDetails,
|
|
IncompleteEvent,
|
|
InProgressEvent,
|
|
OutputTextDeltaEvent,
|
|
QueuedEvent,
|
|
ReasoningStartedEvent,
|
|
ReasoningSummaryTextDeltaEvent,
|
|
ReasoningTextDeltaEvent,
|
|
ResponseObject,
|
|
)
|
|
|
|
|
|
def _resp_obj(status: str = "in_progress") -> ResponseObject:
|
|
"""Build a minimal :class:`ResponseObject` for envelope tests."""
|
|
return ResponseObject(
|
|
id="resp_test",
|
|
object="response",
|
|
status=status,
|
|
model="m",
|
|
created_at=0,
|
|
completed_at=0 if status not in {"queued", "in_progress"} else None,
|
|
output=[],
|
|
background=False,
|
|
store=True,
|
|
usage=None,
|
|
previous_response_id=None,
|
|
conversation={"id": "conv_test"},
|
|
instructions=None,
|
|
reasoning=None,
|
|
error=None,
|
|
incomplete_details=None,
|
|
)
|
|
|
|
|
|
def test_created_event_translates_to_response_created() -> None:
|
|
"""``CreatedEvent`` → :class:`ResponseCreated` carrying the response id.
|
|
|
|
Regression: without this mapping the REPL adapter never sets
|
|
``current_response_id`` and downstream history lookups silently
|
|
target the wrong response.
|
|
"""
|
|
env = CreatedEvent(type="response.created", response=_resp_obj("queued"))
|
|
out = _server_event_to_sdk_event(env)
|
|
assert isinstance(out, ResponseCreated)
|
|
assert out.response.id == "resp_test"
|
|
|
|
|
|
def test_text_delta_event_translates_to_text_delta() -> None:
|
|
"""``OutputTextDeltaEvent`` → :class:`TextDelta` with the same payload.
|
|
|
|
Regression: this is the event the sessions renderer consumes
|
|
to paint assistant text on the screen. A mismatch here means a
|
|
silent "spinner spins forever, no text" REPL.
|
|
"""
|
|
env = OutputTextDeltaEvent(
|
|
type="response.output_text.delta",
|
|
delta="hello",
|
|
)
|
|
out = _server_event_to_sdk_event(env)
|
|
assert isinstance(out, TextDelta)
|
|
assert out.delta == "hello"
|
|
|
|
|
|
def test_queued_in_progress_translate() -> None:
|
|
"""Lifecycle envelopes map to their SDK counterparts."""
|
|
queued = QueuedEvent(type="response.queued", response=_resp_obj("queued"))
|
|
in_prog = InProgressEvent(type="response.in_progress", response=_resp_obj("in_progress"))
|
|
assert isinstance(_server_event_to_sdk_event(queued), ResponseQueued)
|
|
assert isinstance(_server_event_to_sdk_event(in_prog), ResponseInProgress)
|
|
|
|
|
|
def test_terminal_events_translate() -> None:
|
|
"""Each terminal status maps to the right SDK terminal class.
|
|
|
|
Regression: a wrong mapping here causes the REPL to miss the
|
|
turn-terminal event and wait for a stream that already ended.
|
|
"""
|
|
completed = CompletedEvent(type="response.completed", response=_resp_obj("completed"))
|
|
failed = FailedEvent(type="response.failed", response=_resp_obj("failed"))
|
|
cancelled = CancelledEvent(type="response.cancelled", response=_resp_obj("cancelled"))
|
|
assert isinstance(_server_event_to_sdk_event(completed), ResponseCompleted)
|
|
assert isinstance(_server_event_to_sdk_event(failed), ResponseFailed)
|
|
assert isinstance(_server_event_to_sdk_event(cancelled), ResponseCancelled)
|
|
|
|
|
|
def test_incomplete_event_preserves_reason() -> None:
|
|
"""``IncompleteEvent`` carries the ``incomplete_details.reason`` through.
|
|
|
|
The SDK's :class:`ResponseIncomplete` flattens the reason into a
|
|
top-level field. Without this propagation, REPL renderers that
|
|
surface "incomplete: max_tokens" show an empty reason.
|
|
"""
|
|
resp = _resp_obj("incomplete")
|
|
# ResponseObject construction defaults incomplete_details to None;
|
|
# set it explicitly via model_copy so we hit the propagation path.
|
|
resp = resp.model_copy(update={"incomplete_details": IncompleteDetails(reason="max_tokens")})
|
|
env = IncompleteEvent(type="response.incomplete", response=resp)
|
|
out = _server_event_to_sdk_event(env)
|
|
assert isinstance(out, ResponseIncomplete)
|
|
assert out.reason == "max_tokens"
|
|
|
|
|
|
def test_reasoning_events_translate() -> None:
|
|
"""All three reasoning variants map to their SDK counterparts."""
|
|
started = ReasoningStartedEvent(type="response.reasoning.started")
|
|
delta = ReasoningTextDeltaEvent(type="response.reasoning_text.delta", delta="thinking")
|
|
summary = ReasoningSummaryTextDeltaEvent(
|
|
type="response.reasoning_summary_text.delta",
|
|
delta="summary",
|
|
)
|
|
assert isinstance(_server_event_to_sdk_event(started), ReasoningStarted)
|
|
parsed_delta = _server_event_to_sdk_event(delta)
|
|
assert isinstance(parsed_delta, ReasoningDelta)
|
|
assert parsed_delta.delta == "thinking"
|
|
parsed_summary = _server_event_to_sdk_event(summary)
|
|
assert isinstance(parsed_summary, ReasoningSummaryDelta)
|
|
assert parsed_summary.delta == "summary"
|
|
|
|
|
|
def test_unknown_event_returns_none() -> None:
|
|
"""Untranslated event types fall through to ``None`` (forward-compatible)."""
|
|
|
|
class _Unknown:
|
|
type = "response.something.new"
|
|
|
|
assert _server_event_to_sdk_event(_Unknown()) is None
|
|
|
|
|
|
def test_elicitation_request_event_translates() -> None:
|
|
"""``ElicitationRequestEvent`` → :class:`ElicitationRequest` with all fields."""
|
|
from omnigent_client._events import ElicitationRequest
|
|
|
|
from omnigent.server.schemas import (
|
|
ElicitationRequestEvent,
|
|
ElicitationRequestParams,
|
|
)
|
|
|
|
params = ElicitationRequestParams(
|
|
mode="form",
|
|
message="Approve this?",
|
|
requestedSchema={"type": "object"},
|
|
phase="tool_call",
|
|
policy_name="approve_shell",
|
|
content_preview="rm -rf /tmp",
|
|
)
|
|
event = ElicitationRequestEvent(
|
|
type="response.elicitation_request",
|
|
elicitation_id="elicit_abc",
|
|
params=params,
|
|
)
|
|
out = _server_event_to_sdk_event(event)
|
|
assert isinstance(out, ElicitationRequest)
|
|
assert out.elicitation_id == "elicit_abc"
|
|
assert out.message == "Approve this?"
|
|
assert out.mode == "form"
|
|
assert out.phase == "tool_call"
|
|
assert out.policy_name == "approve_shell"
|
|
assert out.content_preview == "rm -rf /tmp"
|
|
assert out.requested_schema == {"type": "object"}
|
|
# Own-session prompt: no mirrored-child routing hint.
|
|
assert out.target_session_id is None
|
|
|
|
|
|
def test_elicitation_request_event_preserves_target_session_id() -> None:
|
|
"""A mirrored sub-agent prompt carries ``target_session_id`` through.
|
|
|
|
The server stamps ``params.target_session_id`` when it mirrors a
|
|
child's parked elicitation into an ancestor stream. If the translator
|
|
drops it (the original bug), the REPL resolves the verdict against the
|
|
parent session, which 404s, and the sub-agent stays blocked forever.
|
|
"""
|
|
from omnigent_client._events import ElicitationRequest
|
|
|
|
from omnigent.server.schemas import (
|
|
ElicitationRequestEvent,
|
|
ElicitationRequestParams,
|
|
)
|
|
|
|
params = ElicitationRequestParams(
|
|
mode="form",
|
|
message="Claude wants to call **Read**",
|
|
phase="tool_call",
|
|
policy_name="claude_sdk_permission",
|
|
content_preview="Read(builder.py)",
|
|
target_session_id="conv_child123",
|
|
)
|
|
event = ElicitationRequestEvent(
|
|
type="response.elicitation_request",
|
|
elicitation_id="elicit_child",
|
|
params=params,
|
|
)
|
|
out = _server_event_to_sdk_event(event)
|
|
assert isinstance(out, ElicitationRequest)
|
|
# The child id survived translation; a None here is the original
|
|
# dropped-field bug that misroutes the verdict to the parent.
|
|
assert out.target_session_id == "conv_child123"
|
|
|
|
|
|
def test_elicitation_resolve_session_id_routes_mirrored_child_to_child() -> None:
|
|
"""Mirrored child prompt resolves against the child, own prompt against the stream.
|
|
|
|
This is the routing decision the render callback makes before POSTing
|
|
a verdict: a mirrored sub-agent prompt (``target_session_id`` set)
|
|
must resolve on the child that parked on it; an own-session prompt
|
|
(unset) resolves on the session the event arrived on.
|
|
"""
|
|
from omnigent_client._events import ElicitationRequest
|
|
|
|
from omnigent.repl._repl import _elicitation_resolve_session_id
|
|
|
|
mirrored = ElicitationRequest(
|
|
elicitation_id="elicit_child",
|
|
message="Claude wants to call **Read**",
|
|
requested_schema={},
|
|
mode="form",
|
|
phase="tool_call",
|
|
policy_name="claude_sdk_permission",
|
|
content_preview="Read(builder.py)",
|
|
target_session_id="conv_child123",
|
|
)
|
|
# Mirrored: verdict goes to the parked child, not the parent stream.
|
|
assert _elicitation_resolve_session_id(mirrored, "conv_parent") == "conv_child123"
|
|
|
|
own = ElicitationRequest(
|
|
elicitation_id="elicit_own",
|
|
message="Approve this?",
|
|
requested_schema={},
|
|
mode="form",
|
|
phase="tool_call",
|
|
policy_name="approve_shell",
|
|
content_preview="rm -rf /tmp",
|
|
)
|
|
# Own-session prompt: no hint, so resolve on the receiving stream.
|
|
assert _elicitation_resolve_session_id(own, "conv_parent") == "conv_parent"
|
|
|
|
|
|
def test_output_item_done_event_passes_through() -> None:
|
|
"""``OutputItemDoneEvent`` is returned as-is for adapter-level handling."""
|
|
from omnigent.server.schemas import OutputItemDoneEvent
|
|
|
|
event = OutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "function_call",
|
|
"status": "action_required",
|
|
"call_id": "c1",
|
|
"name": "bash",
|
|
},
|
|
)
|
|
out = _server_event_to_sdk_event(event)
|
|
assert isinstance(out, OutputItemDoneEvent)
|
|
assert out.item["call_id"] == "c1"
|
|
|
|
|
|
def test_output_item_done_message_passes_through() -> None:
|
|
"""``OutputItemDoneEvent`` with a message item passes through.
|
|
|
|
The translator does not filter by item type; the render callback
|
|
is responsible for skipping message items (which are already
|
|
rendered via ``TextDelta`` streaming) and only rendering
|
|
``function_call`` / ``function_call_output`` as history entries.
|
|
"""
|
|
from omnigent.server.schemas import OutputItemDoneEvent
|
|
|
|
event = OutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "hello"}],
|
|
},
|
|
)
|
|
out = _server_event_to_sdk_event(event)
|
|
assert isinstance(out, OutputItemDoneEvent)
|
|
assert out.item["type"] == "message"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("item_type", "role", "saw_text_deltas", "expect_flush", "expect_render"),
|
|
[
|
|
# Regression (the "growing preamble" bug): a tool call interrupts
|
|
# streamed prose. The executor emits no per-block ``message``
|
|
# boundary, so this is the ONLY chance to commit the in-flight
|
|
# text. It MUST flush — otherwise the next text block appends to
|
|
# the prior block's buffer and the live region re-renders the
|
|
# whole turn's prose on every later delta.
|
|
("function_call", None, True, True, True),
|
|
("function_call_output", None, True, True, True),
|
|
("slash_command", None, True, True, True),
|
|
# Same item types with no in-flight prose: nothing to commit, but
|
|
# the item still renders as a history entry.
|
|
("function_call", None, False, False, True),
|
|
# Assistant message after streamed deltas: commit the trailing
|
|
# tail at the boundary, but DON'T re-render the full item (the
|
|
# deltas already rendered it — re-rendering would duplicate it).
|
|
("message", "assistant", True, True, False),
|
|
# Assistant message with no streamed deltas (non-streaming
|
|
# harness): render the full item, nothing to flush.
|
|
("message", "assistant", False, False, True),
|
|
# User messages and non-renderable items (reasoning) are handled
|
|
# by other branches; the plan neither flushes nor renders them.
|
|
("message", "user", False, False, False),
|
|
("reasoning", None, True, False, False),
|
|
],
|
|
)
|
|
def test_plan_output_item_render(
|
|
item_type: str,
|
|
role: str | None,
|
|
saw_text_deltas: bool,
|
|
expect_flush: bool,
|
|
expect_render: bool,
|
|
) -> None:
|
|
"""
|
|
``_plan_output_item_render`` commits in-flight prose at every
|
|
content-block boundary that interrupts streamed text.
|
|
|
|
A single turn interleaves assistant text blocks with tool calls,
|
|
but the streaming executor emits the assistant ``message`` output
|
|
item only once (after all deltas) — never between text blocks. A
|
|
``function_call`` arriving mid-stream is therefore the only signal
|
|
that one text block ended; the plan must request a flush there
|
|
(``expect_flush``) or the formatter's paragraph buffer accumulates
|
|
across blocks and the preamble visibly grows on every later delta.
|
|
|
|
:param item_type: The streamed item's ``type`` field.
|
|
:param role: The item's ``role`` (only set on ``message`` items).
|
|
:param saw_text_deltas: Whether assistant prose is in flight.
|
|
:param expect_flush: Expected ``flush_inflight_text`` decision.
|
|
:param expect_render: Expected ``render_item`` decision.
|
|
"""
|
|
plan = _plan_output_item_render(item_type, role, saw_text_deltas)
|
|
assert plan.flush_inflight_text is expect_flush, (
|
|
f"{item_type!r} (role={role!r}, saw_text_deltas={saw_text_deltas}) "
|
|
f"should flush={expect_flush}. A wrong flush decision at a tool-call "
|
|
f"boundary is exactly the streaming-text duplication bug: without the "
|
|
f"flush the next text block appends to the prior block's buffer."
|
|
)
|
|
assert plan.render_item is expect_render, (
|
|
f"{item_type!r} (role={role!r}, saw_text_deltas={saw_text_deltas}) "
|
|
f"should render={expect_render}. Re-rendering an assistant message "
|
|
f"whose prose already streamed would duplicate it; skipping a tool "
|
|
f"call would drop it from the transcript."
|
|
)
|
|
|
|
|
|
def _assistant_message_item(text: str) -> dict[str, object]:
|
|
"""
|
|
Build an assistant ``message`` output item with one text block.
|
|
|
|
Shape matches what the relay's ``_flush_relay_text`` publishes after
|
|
persisting a streamed text segment (``response.output_item.done``).
|
|
|
|
:param text: The message's output text, e.g. ``"Got it — done."``.
|
|
:returns: The ``item`` dict of the ``output_item.done`` event.
|
|
"""
|
|
return {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": text}],
|
|
}
|
|
|
|
|
|
def test_prose_tracker_suppresses_persisted_segment_copy() -> None:
|
|
"""The relay's persisted copy of a streamed segment is matched once.
|
|
|
|
Regression for the mid-turn duplication bug: the relay flushes a
|
|
streamed text segment at a tool-call boundary and publishes the
|
|
persisted item as ``output_item.done``. By then the tool-call item
|
|
already committed the in-flight prose, so the delta-based skip
|
|
can't catch the item — the content match must.
|
|
"""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("Got it — ")
|
|
tracker.on_delta("dispatching.")
|
|
# Tool-call boundary commits the in-flight prose (mirrors
|
|
# _flush_inflight_assistant_text resetting _saw_text_deltas).
|
|
tracker.commit_segment()
|
|
|
|
# The persisted copy byte-matches the streamed segment: it must be
|
|
# consumed (suppressed). A False here re-renders the whole segment
|
|
# as a duplicate "◆ agent + text" block — the reported bug.
|
|
assert tracker.consume_match(_assistant_message_item("Got it — dispatching.")) is True
|
|
# The entry is consumed: an identical item later in the turn has no
|
|
# streamed counterpart left and must render (it is real content).
|
|
assert tracker.consume_match(_assistant_message_item("Got it — dispatching.")) is False
|
|
|
|
|
|
def test_prose_tracker_does_not_match_unstreamed_text() -> None:
|
|
"""A non-streamed assistant message must not be suppressed.
|
|
|
|
If the item's text differs from every committed segment, the
|
|
message never streamed as deltas (e.g. a non-streaming harness)
|
|
and skipping it would drop real content from the transcript.
|
|
"""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("Streamed prose.")
|
|
tracker.commit_segment()
|
|
|
|
assert tracker.consume_match(_assistant_message_item("Different text.")) is False
|
|
# The streamed segment's entry is untouched by the failed match.
|
|
assert tracker.consume_match(_assistant_message_item("Streamed prose.")) is True
|
|
|
|
|
|
def test_prose_tracker_multiset_handles_identical_segments() -> None:
|
|
"""Two identical streamed segments suppress exactly two items.
|
|
|
|
Multiset semantics: a turn that legitimately narrates the same text
|
|
twice (two flushed segments) gets two persisted-item publishes; each
|
|
must match its own entry, and a third identical item must render.
|
|
"""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("Done.")
|
|
tracker.commit_segment()
|
|
tracker.on_delta("Done.")
|
|
tracker.commit_segment()
|
|
|
|
assert tracker.consume_match(_assistant_message_item("Done.")) is True
|
|
assert tracker.consume_match(_assistant_message_item("Done.")) is True
|
|
# Both entries consumed — a third identical message is new content.
|
|
assert tracker.consume_match(_assistant_message_item("Done.")) is False
|
|
|
|
|
|
def test_prose_tracker_reset_turn_drops_prior_turn_prose() -> None:
|
|
"""A new turn must not suppress messages with last turn's text."""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("Same words.")
|
|
tracker.commit_segment()
|
|
tracker.reset_turn()
|
|
|
|
# Post-reset, the prior turn's segment is gone: an assistant message
|
|
# with identical text this turn is genuinely new and must render.
|
|
assert tracker.consume_match(_assistant_message_item("Same words.")) is False
|
|
|
|
|
|
def test_prose_tracker_ignores_items_without_output_text() -> None:
|
|
"""Items with no ``output_text`` content never match anything."""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("prose")
|
|
tracker.commit_segment()
|
|
|
|
# No content list at all.
|
|
assert tracker.consume_match({"type": "message", "role": "assistant"}) is False
|
|
# Content present but no output_text blocks (e.g. refusal-only).
|
|
assert (
|
|
tracker.consume_match(
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "refusal", "refusal": "no"}],
|
|
}
|
|
)
|
|
is False
|
|
)
|
|
# The streamed entry survives both non-matches.
|
|
assert tracker.consume_match(_assistant_message_item("prose")) is True
|
|
|
|
|
|
def test_prose_tracker_joins_multi_block_item_content() -> None:
|
|
"""An item with several ``output_text`` blocks matches by joined text.
|
|
|
|
Mirrors the server-side join in ``_flush_relay_text`` /
|
|
``_committed_message_text``: content blocks concatenate in order.
|
|
"""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("part one ")
|
|
tracker.on_delta("part two")
|
|
tracker.commit_segment()
|
|
|
|
item = {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "output_text", "text": "part one "},
|
|
{"type": "output_text", "text": "part two"},
|
|
],
|
|
}
|
|
assert tracker.consume_match(item) is True
|
|
|
|
|
|
def test_prose_tracker_uncommitted_segment_does_not_match() -> None:
|
|
"""Only COMMITTED segments are candidates for suppression.
|
|
|
|
While prose is still in flight, ``_saw_text_deltas`` is True and the
|
|
delta-based skip handles the item; the tracker must not also match
|
|
(the call-site short-circuits, but the tracker's own contract is
|
|
that uncommitted parts are not yet a segment).
|
|
"""
|
|
tracker = _TurnProseTracker()
|
|
tracker.on_delta("in flight")
|
|
|
|
assert tracker.consume_match(_assistant_message_item("in flight")) is False
|
|
|
|
|
|
def test_client_task_cancel_event_passes_through() -> None:
|
|
"""``ClientTaskCancelEvent`` is returned as-is for adapter-level handling."""
|
|
from omnigent.server.schemas import ClientTaskCancelEvent
|
|
|
|
event = ClientTaskCancelEvent(
|
|
type="response.client_task.cancel",
|
|
task_id="resp_async_abc",
|
|
call_id="call_xyz",
|
|
)
|
|
out = _server_event_to_sdk_event(event)
|
|
assert isinstance(out, ClientTaskCancelEvent)
|
|
assert out.task_id == "resp_async_abc"
|
|
assert out.call_id == "call_xyz"
|
|
|
|
|
|
def test_render_callback_event_flow() -> None:
|
|
"""Replay a real SSE event stream and assert rendering order.
|
|
|
|
Given the event sequence observed from a real server:
|
|
|
|
1. session.status running
|
|
2. session.input.consumed
|
|
3. response.output_text.delta
|
|
4. response.output_item.done (message)
|
|
5. response.completed
|
|
6. session.status idle
|
|
|
|
The render callback should produce:
|
|
|
|
- header on session.status running
|
|
- streamed text on delta
|
|
- NO duplicate from output_item.done (message skipped)
|
|
- flush + turn_done on session.status idle
|
|
"""
|
|
|
|
from pydantic import TypeAdapter
|
|
|
|
from omnigent.server.schemas import ServerStreamEvent
|
|
|
|
adapter = TypeAdapter(ServerStreamEvent)
|
|
|
|
raw_events = [
|
|
{
|
|
"type": "session.status",
|
|
"conversation_id": "conv_test",
|
|
"status": "running",
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "session.input.consumed",
|
|
"data": {
|
|
"item_id": "msg_1",
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "say hello"}],
|
|
"agent": None,
|
|
},
|
|
},
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "response.output_text.delta",
|
|
"delta": "Hello, hope you are well!",
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "response.output_item.done",
|
|
"item": {
|
|
"id": "msg_2",
|
|
"response_id": "resp_1",
|
|
"type": "message",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello, hope you are well!"}],
|
|
"model": "test-agent",
|
|
},
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": "resp_1",
|
|
"object": "response",
|
|
"status": "completed",
|
|
"model": "test-agent",
|
|
"created_at": 0,
|
|
"completed_at": 0,
|
|
"output": [],
|
|
"background": False,
|
|
"store": True,
|
|
"usage": None,
|
|
"previous_response_id": None,
|
|
"conversation": {"id": "conv_test"},
|
|
"instructions": None,
|
|
"reasoning": None,
|
|
"error": None,
|
|
"incomplete_details": None,
|
|
},
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "session.status",
|
|
"conversation_id": "conv_test",
|
|
"status": "idle",
|
|
"sequence_number": None,
|
|
},
|
|
]
|
|
|
|
events = [adapter.validate_python(e) for e in raw_events]
|
|
|
|
# Build a minimal mock adapter + callback to track rendering.
|
|
rendered: list[str] = []
|
|
|
|
class _FakeSession:
|
|
_agent_name = "test-agent"
|
|
_current_response_id: str | None = None
|
|
_is_streaming = True
|
|
_tool_callables = None
|
|
_turn_done = None
|
|
# Mimic a local send: ``send()`` increments this before POST so
|
|
# the matching ``session.input.consumed`` event is suppressed.
|
|
_pending_local_user_sends = 1
|
|
|
|
fake_session = _FakeSession()
|
|
|
|
# Import the schemas needed by the callback.
|
|
from omnigent_client._events import (
|
|
ResponseCreated as _Created,
|
|
)
|
|
from omnigent_client._events import (
|
|
TextDelta as _TD,
|
|
)
|
|
|
|
from omnigent.server.schemas import (
|
|
OutputItemDoneEvent as _OIDE,
|
|
)
|
|
from omnigent.server.schemas import (
|
|
SessionInputConsumedEvent as _SICEv,
|
|
)
|
|
from omnigent.server.schemas import (
|
|
SessionStatusEvent as _StatusEv,
|
|
)
|
|
|
|
# Replay events through the same logic as _render_session_event.
|
|
for event in events:
|
|
if isinstance(event, _StatusEv):
|
|
if event.status == "running":
|
|
# Mirrors host.start_timer() call so a cross-client
|
|
# turn surfaces the toolbar spinner.
|
|
rendered.append("START_TIMER")
|
|
rendered.append("HEADER")
|
|
elif event.status in ("idle", "failed"):
|
|
rendered.append("FLUSH")
|
|
rendered.append("STOP_TIMER")
|
|
continue
|
|
|
|
if isinstance(event, _SICEv):
|
|
if event.data.type == "message" and event.data.data.get("role") == "user":
|
|
if fake_session._pending_local_user_sends > 0:
|
|
fake_session._pending_local_user_sends -= 1
|
|
else:
|
|
rendered.append("USER_MESSAGE_CROSSCLIENT")
|
|
continue
|
|
|
|
sdk_ev = _server_event_to_sdk_event(event)
|
|
if sdk_ev is None:
|
|
continue
|
|
|
|
if isinstance(sdk_ev, _TD):
|
|
rendered.append(f"TEXT_DELTA:{sdk_ev.delta}")
|
|
continue
|
|
|
|
if isinstance(sdk_ev, _OIDE):
|
|
item = sdk_ev.item
|
|
if isinstance(item, dict):
|
|
item_type = item.get("type")
|
|
if item_type in ("function_call", "function_call_output"):
|
|
rendered.append(f"TOOL:{item_type}")
|
|
elif item_type == "message" and item.get("role") == "assistant":
|
|
if not fake_session._is_streaming:
|
|
rendered.append("ASSISTANT_MESSAGE_HISTORY")
|
|
# else: skipped (already streamed via deltas)
|
|
continue
|
|
|
|
if isinstance(sdk_ev, _Created):
|
|
fake_session._current_response_id = sdk_ev.response.id
|
|
continue
|
|
|
|
# Assert the expected rendering order.
|
|
assert rendered == [
|
|
"START_TIMER",
|
|
"HEADER",
|
|
# session.input.consumed for the local send is SUPPRESSED
|
|
# (FIFO decrement); ``on_input`` already echoed via fmt.user_message.
|
|
"TEXT_DELTA:Hello, hope you are well!",
|
|
# output_item.done for message is SKIPPED (is_streaming=True)
|
|
# response.completed is ignored
|
|
"FLUSH",
|
|
"STOP_TIMER",
|
|
]
|
|
# Counter is exhausted by the matching event.
|
|
assert fake_session._pending_local_user_sends == 0
|
|
|
|
|
|
def test_session_input_consumed_renders_cross_client_user_message() -> None:
|
|
"""``session.input.consumed`` from another client renders a user bubble.
|
|
|
|
When the local send FIFO counter is zero the event
|
|
must have originated from a different client (Web UI ↔ TUI on the
|
|
same session); the renderer pulls the text from the event payload
|
|
and emits it via the same ``fmt.user_message`` echo a local send
|
|
would produce.
|
|
"""
|
|
from pydantic import TypeAdapter
|
|
|
|
from omnigent.server.schemas import (
|
|
ServerStreamEvent,
|
|
SessionInputConsumedEvent,
|
|
)
|
|
|
|
adapter = TypeAdapter(ServerStreamEvent)
|
|
event = adapter.validate_python(
|
|
{
|
|
"type": "session.input.consumed",
|
|
"data": {
|
|
"item_id": "msg_from_other_client",
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "from web ui"}],
|
|
},
|
|
},
|
|
"sequence_number": None,
|
|
}
|
|
)
|
|
assert isinstance(event, SessionInputConsumedEvent)
|
|
|
|
class _FakeSession:
|
|
_pending_local_user_sends = 0 # Counter is empty → cross-client.
|
|
|
|
fake_session = _FakeSession()
|
|
rendered: list[str] = []
|
|
if event.data.type == "message" and event.data.data.get("role") == "user":
|
|
if fake_session._pending_local_user_sends > 0:
|
|
fake_session._pending_local_user_sends -= 1
|
|
else:
|
|
content = event.data.data.get("content", [])
|
|
text = " ".join(
|
|
b.get("text", "")
|
|
for b in content
|
|
if isinstance(b, dict) and b.get("type") in ("input_text", "output_text")
|
|
)
|
|
rendered.append(f"USER_MESSAGE:{text}")
|
|
assert rendered == ["USER_MESSAGE:from web ui"]
|
|
|
|
|
|
def test_autonomous_turn_renders_without_local_send() -> None:
|
|
"""An autonomous turn (no preceding local ``send()``) reaches the host.
|
|
|
|
Pins the renderer-chain coverage that the deleted
|
|
``test_run_omnigent_autonomous_turn_polling.py`` used to provide before
|
|
the items-poller path was retired. Under the new SSE architecture,
|
|
autonomous turns (timer-fired notifications, async-tool completions,
|
|
cross-client assistant turns) flow through the same
|
|
``_render_session_event`` callback as local sends, but without a
|
|
matching ``session.input.consumed`` and without the local
|
|
``_pending_local_user_sends`` counter being incremented.
|
|
|
|
The required rendering invariants for an autonomous turn are:
|
|
|
|
* ``session.status running`` → header + start-timer.
|
|
* ``response.output_text.delta`` → streamed text via formatter.
|
|
* ``session.status idle`` → flush + stop-timer + turn-done.
|
|
|
|
If ``_render_session_event`` ever regresses on the
|
|
``_saw_text_deltas = False`` reset (so subsequent autonomous turns
|
|
in the same session double-render via ``output_item.done``), or
|
|
drops the ``running`` header for non-local-send paths, this test
|
|
fails and a fresh assistant turn never reaches the user's screen.
|
|
|
|
:returns: None.
|
|
"""
|
|
from pydantic import TypeAdapter
|
|
|
|
from omnigent.server.schemas import ServerStreamEvent
|
|
|
|
adapter = TypeAdapter(ServerStreamEvent)
|
|
|
|
# No ``session.input.consumed`` in this stream — the turn was
|
|
# NOT triggered by the local client. The renderer must still
|
|
# produce the same output as a local-send turn.
|
|
raw_events = [
|
|
{
|
|
"type": "session.status",
|
|
"conversation_id": "conv_test",
|
|
"status": "running",
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "response.output_text.delta",
|
|
"delta": "timer fired: e2e_marker",
|
|
"sequence_number": None,
|
|
},
|
|
{
|
|
"type": "session.status",
|
|
"conversation_id": "conv_test",
|
|
"status": "idle",
|
|
"sequence_number": None,
|
|
},
|
|
]
|
|
events = [adapter.validate_python(e) for e in raw_events]
|
|
|
|
rendered: list[str] = []
|
|
|
|
class _FakeSession:
|
|
_agent_name = "test-agent"
|
|
_current_response_id: str | None = None
|
|
_is_streaming = True
|
|
_tool_callables = None
|
|
_turn_done = None
|
|
# Counter is empty: no local ``send()`` preceded this turn.
|
|
# The autonomous-turn invariant is "renders even though the
|
|
# local FIFO is empty," so this MUST stay at 0 throughout.
|
|
_pending_local_user_sends = 0
|
|
|
|
fake_session = _FakeSession()
|
|
|
|
from omnigent.server.schemas import (
|
|
SessionInputConsumedEvent as _SICEv,
|
|
)
|
|
from omnigent.server.schemas import (
|
|
SessionStatusEvent as _StatusEv,
|
|
)
|
|
|
|
# Replay events through the same branching logic as
|
|
# ``_render_session_event`` in ``omnigent/repl/_repl.py``.
|
|
for event in events:
|
|
if isinstance(event, _StatusEv):
|
|
if event.status == "running":
|
|
rendered.append("START_TIMER")
|
|
rendered.append("HEADER")
|
|
elif event.status in ("idle", "failed"):
|
|
rendered.append("FLUSH")
|
|
rendered.append("STOP_TIMER")
|
|
continue
|
|
|
|
if isinstance(event, _SICEv): # pragma: no cover - none in this stream
|
|
raise AssertionError("autonomous-turn stream must not contain session.input.consumed")
|
|
|
|
sdk_ev = _server_event_to_sdk_event(event)
|
|
if sdk_ev is None:
|
|
continue
|
|
if isinstance(sdk_ev, TextDelta):
|
|
rendered.append(f"TEXT_DELTA:{sdk_ev.delta}")
|
|
|
|
# The autonomous-turn render must produce the same shape as a
|
|
# local-send turn (sans the suppressed ``session.input.consumed``
|
|
# echo) — header on running, streamed text, flush + stop-timer
|
|
# on idle. If any of the four entries is missing or the order
|
|
# changes, a timer-fired or cross-client assistant turn never
|
|
# reaches the user's screen.
|
|
assert rendered == [
|
|
"START_TIMER",
|
|
"HEADER",
|
|
"TEXT_DELTA:timer fired: e2e_marker",
|
|
"FLUSH",
|
|
"STOP_TIMER",
|
|
]
|
|
# The local-send FIFO is undisturbed — proves the renderer did
|
|
# not accidentally consume a phantom slot for the autonomous turn.
|
|
assert fake_session._pending_local_user_sends == 0
|
|
|
|
|
|
def test_response_failed_translates_with_error_message() -> None:
|
|
"""``ResponseFailed`` carries the error message from the response.
|
|
|
|
Regression: ``_render_session_event`` had no handler for
|
|
``ResponseFailed`` — the event fell through all ``isinstance``
|
|
checks and was silently dropped. The TUI showed an empty
|
|
response while the web UI rendered the error correctly.
|
|
"""
|
|
resp = _resp_obj("failed")
|
|
resp = resp.model_copy(
|
|
update={"error": {"code": "BAD_REQUEST", "message": "unsupported API type"}}
|
|
)
|
|
failed = FailedEvent(type="response.failed", response=resp)
|
|
sdk_ev = _server_event_to_sdk_event(failed)
|
|
assert isinstance(sdk_ev, ResponseFailed)
|
|
assert sdk_ev.response.error is not None
|
|
assert sdk_ev.response.error.message == "unsupported API type"
|
|
assert sdk_ev.response.error.code == "BAD_REQUEST"
|
|
|
|
|
|
def test_failed_status_event_renders_error_message() -> None:
|
|
"""A terminal ``session.status: failed`` renders its error message.
|
|
|
|
Regression: a SETUP-phase failure (spec resolution, spawn-env
|
|
build) ends the turn before the LLM stream starts, so the runner
|
|
publishes only ``session.status: failed`` (carrying the error) —
|
|
no ``response.failed`` event is ever emitted. The REPL handled
|
|
``failed`` identically to ``idle`` (empty ``format_text_done``),
|
|
so the working spinner vanished with no output: the failure was
|
|
silent to the user. ``_render_failed_status_error`` must surface
|
|
the carried message as a red error line.
|
|
"""
|
|
from omnigent_ui_sdk import RichBlockFormatter
|
|
|
|
from omnigent.repl._repl import _render_failed_status_error
|
|
from omnigent.server.schemas import ErrorDetail
|
|
from omnigent.server.schemas import SessionStatusEvent as _StatusEv
|
|
from tests.repl.helpers import CapturingHost
|
|
|
|
host = CapturingHost()
|
|
event = _StatusEv(
|
|
type="session.status",
|
|
conversation_id="conv_test",
|
|
status="failed",
|
|
error=ErrorDetail(
|
|
code="runner_error",
|
|
message="turn setup failed: no resolvable model for provider 'acme'",
|
|
),
|
|
)
|
|
|
|
items = _render_failed_status_error(RichBlockFormatter(), host, event) # type: ignore[arg-type]
|
|
|
|
# The exact error CONTENT must reach the screen — not just that
|
|
# *something* was rendered. Without the fix host.text is empty.
|
|
assert "turn setup failed: no resolvable model for provider 'acme'" in host.text
|
|
# The helper returns the rendered items so the caller can record
|
|
# them on the debug tape; an empty list would mean nothing rendered.
|
|
assert items
|
|
|
|
|
|
def test_failed_status_event_without_error_falls_back() -> None:
|
|
"""A ``failed`` status with no error detail renders a generic line.
|
|
|
|
Some runner failure call sites carry only an HTTP status (no
|
|
``message``), and the schema's ``error`` field is optional, so the
|
|
event may arrive with ``error=None``. The renderer must not crash
|
|
on the missing field and must still print a non-empty error line
|
|
rather than ending the turn silently.
|
|
"""
|
|
from omnigent_ui_sdk import RichBlockFormatter
|
|
|
|
from omnigent.repl._repl import _render_failed_status_error
|
|
from omnigent.server.schemas import SessionStatusEvent as _StatusEv
|
|
from tests.repl.helpers import CapturingHost
|
|
|
|
host = CapturingHost()
|
|
event = _StatusEv(
|
|
type="session.status",
|
|
conversation_id="conv_test",
|
|
status="failed",
|
|
error=None,
|
|
)
|
|
|
|
items = _render_failed_status_error(RichBlockFormatter(), host, event) # type: ignore[arg-type]
|
|
|
|
assert "turn failed" in host.text
|
|
assert items
|