7396 lines
279 KiB
Python
7396 lines
279 KiB
Python
"""Integration tests for /v1/sessions endpoints.
|
||
|
||
Exercises every sessions-API surface added in the migration:
|
||
``GET /v1/sessions``, ``PATCH /v1/sessions/{id}``,
|
||
``GET /v1/sessions/{id}/items``, title/labels on create, and
|
||
function_call_output routing.
|
||
|
||
Uses the shared ``client`` fixture from ``tests/server/conftest.py``
|
||
(real stores + mock LLM) so the tests hit the real route → store
|
||
pipeline without subprocesses.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from omnigent.llms.context_window import ModelPricing
|
||
from omnigent.runtime.tool_output import MAX_TOOL_OUTPUT_BYTES
|
||
from omnigent.spec.types import SkillSpec
|
||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||
SqlAlchemyConversationStore,
|
||
)
|
||
from omnigent.stores.host_store import HostStore
|
||
from omnigent.tools.builtins.load_skill import format_skill_meta_text
|
||
from tests.server.helpers import create_test_agent
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────
|
||
|
||
|
||
async def _create_session(
|
||
client: httpx.AsyncClient,
|
||
agent_id: str,
|
||
*,
|
||
initial_message: str | None = None,
|
||
title: str | None = None,
|
||
labels: dict[str, str] | None = None,
|
||
terminal_launch_args: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Create a session and return the response JSON.
|
||
|
||
:param client: The test HTTP client.
|
||
:param agent_id: Agent to bind, e.g. ``"ag_abc123"``.
|
||
:param initial_message: When set, seed the session with a
|
||
user message.
|
||
:param title: Optional session title.
|
||
:param labels: Optional initial labels.
|
||
:param terminal_launch_args: Optional native-terminal
|
||
pass-through CLI args, e.g.
|
||
``["--dangerously-skip-permissions"]``.
|
||
:returns: The ``POST /v1/sessions`` response body.
|
||
"""
|
||
payload: dict[str, Any] = {"agent_id": agent_id}
|
||
if initial_message is not None:
|
||
payload["initial_items"] = [
|
||
{
|
||
"type": "message",
|
||
"data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": initial_message}],
|
||
},
|
||
},
|
||
]
|
||
if title is not None:
|
||
payload["title"] = title
|
||
if labels is not None:
|
||
payload["labels"] = labels
|
||
if terminal_launch_args is not None:
|
||
payload["terminal_launch_args"] = terminal_launch_args
|
||
resp = await client.post("/v1/sessions", json=payload)
|
||
assert resp.status_code == 201, f"session create failed: {resp.status_code} {resp.text}"
|
||
return resp.json()
|
||
|
||
|
||
async def _wait_for_idle(
|
||
client: httpx.AsyncClient,
|
||
session_id: str,
|
||
*,
|
||
timeout_s: float = 30.0,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Poll ``GET /v1/sessions/{id}`` until the session reaches
|
||
``idle`` or ``failed``.
|
||
|
||
:param client: The test HTTP client.
|
||
:param session_id: Session to poll.
|
||
:param timeout_s: Maximum seconds to wait.
|
||
:returns: The terminal session snapshot.
|
||
"""
|
||
deadline = asyncio.get_event_loop().time() + timeout_s
|
||
snap: dict[str, Any] = {}
|
||
while asyncio.get_event_loop().time() < deadline:
|
||
resp = await client.get(f"/v1/sessions/{session_id}")
|
||
snap = resp.json()
|
||
if snap["status"] in ("idle", "failed"):
|
||
return snap
|
||
await asyncio.sleep(0.1)
|
||
raise AssertionError(
|
||
f"session {session_id} did not reach idle/failed within {timeout_s}s; "
|
||
f"snapshot={json.dumps(snap, indent=2)}"
|
||
)
|
||
|
||
|
||
# ── POST /v1/sessions with title and labels ─────────────
|
||
|
||
|
||
async def test_create_session_with_title_and_labels(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Title and labels flow through to the created session snapshot."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
title="my test session",
|
||
labels={"env": "test", "priority": "high"},
|
||
)
|
||
assert session["title"] == "my test session"
|
||
assert session["labels"]["env"] == "test"
|
||
assert session["labels"]["priority"] == "high"
|
||
|
||
|
||
async def test_create_session_without_title_returns_none(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Omitting title returns null in the snapshot."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
assert session["title"] is None
|
||
|
||
|
||
# ── GET /v1/sessions (list) ──────────────────────────────
|
||
|
||
|
||
async def test_list_sessions_returns_only_sessions(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions`` returns sessions (conversations with
|
||
agent_id), not legacy conversations created via /v1/responses.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
s1 = await _create_session(client, agent["id"], title="session-1")
|
||
s2 = await _create_session(client, agent["id"], title="session-2")
|
||
|
||
resp = await client.get("/v1/sessions")
|
||
assert resp.status_code == 200
|
||
data = resp.json()["data"]
|
||
session_ids = {s["id"] for s in data}
|
||
assert s1["id"] in session_ids
|
||
assert s2["id"] in session_ids
|
||
for s in data:
|
||
assert s["agent_id"] is not None
|
||
|
||
|
||
async def test_list_sessions_filters_by_agent_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""``agent_id`` query param scopes to sessions bound to that agent.
|
||
|
||
The filter uses the ``conversations.agent_id`` column directly
|
||
(the tasks table has been removed). Sessions carry their agent
|
||
binding in the conversation row, so no seeding is required.
|
||
"""
|
||
a1 = await create_test_agent(client, name="agent-a")
|
||
a2 = await create_test_agent(client, name="agent-b")
|
||
s1 = await _create_session(client, a1["id"], title="a1-session")
|
||
await _create_session(client, a2["id"], title="a2-session")
|
||
|
||
resp = await client.get("/v1/sessions", params={"agent_id": a1["id"]})
|
||
assert resp.status_code == 200
|
||
data = resp.json()["data"]
|
||
ids = {s["id"] for s in data}
|
||
assert s1["id"] in ids
|
||
assert all(s["agent_id"] == a1["id"] for s in data)
|
||
|
||
|
||
async def test_list_sessions_pagination(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Cursor pagination works with limit and after."""
|
||
agent = await create_test_agent(client)
|
||
sessions = []
|
||
for i in range(3):
|
||
s = await _create_session(client, agent["id"], title=f"s-{i}")
|
||
sessions.append(s)
|
||
|
||
resp = await client.get("/v1/sessions", params={"limit": 1, "order": "asc"})
|
||
assert resp.status_code == 200
|
||
page1 = resp.json()
|
||
assert len(page1["data"]) == 1
|
||
assert page1["has_more"] is True
|
||
|
||
resp = await client.get(
|
||
"/v1/sessions",
|
||
params={"limit": 1, "order": "asc", "after": page1["data"][0]["id"]},
|
||
)
|
||
page2 = resp.json()
|
||
assert len(page2["data"]) == 1
|
||
assert page2["data"][0]["id"] != page1["data"][0]["id"]
|
||
|
||
|
||
async def test_list_sessions_kind_filter(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
``kind`` scopes the list: ``default`` (the default) hides
|
||
sub-agent children, ``sub_agent`` returns only them, and ``any``
|
||
returns both. ``any`` powers the new-session agent picker's
|
||
discovery of agents that are only bound to sub-agent sessions.
|
||
"""
|
||
agent = await create_test_agent(client, name="kind-filter-agent")
|
||
parent = await _create_session(client, agent["id"], title="kind-parent")
|
||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||
child = conv_store.create_conversation(
|
||
kind="sub_agent",
|
||
title="coder:kind-child",
|
||
parent_conversation_id=parent["id"],
|
||
agent_id=agent["id"],
|
||
)
|
||
|
||
# Omitting ``kind`` keeps the pre-param behavior: the sidebar's
|
||
# view lists only top-level sessions. The child appearing here
|
||
# would mean the default regressed to unfiltered.
|
||
resp = await client.get("/v1/sessions", params={"agent_id": agent["id"]})
|
||
assert resp.status_code == 200
|
||
default_ids = {s["id"] for s in resp.json()["data"]}
|
||
assert parent["id"] in default_ids
|
||
assert child.id not in default_ids
|
||
|
||
# kind=any returns the union, and the child row carries its agent
|
||
# binding — the field the picker harvests for discovery.
|
||
resp = await client.get("/v1/sessions", params={"agent_id": agent["id"], "kind": "any"})
|
||
assert resp.status_code == 200
|
||
any_rows = {s["id"]: s for s in resp.json()["data"]}
|
||
assert parent["id"] in any_rows
|
||
assert child.id in any_rows
|
||
assert any_rows[child.id]["agent_id"] == agent["id"]
|
||
|
||
# kind=sub_agent returns only the child for this agent.
|
||
resp = await client.get("/v1/sessions", params={"agent_id": agent["id"], "kind": "sub_agent"})
|
||
assert resp.status_code == 200
|
||
sub_ids = {s["id"] for s in resp.json()["data"]}
|
||
assert sub_ids == {child.id}
|
||
|
||
# Values outside default|sub_agent|any are rejected by the Query
|
||
# pattern, not silently passed through to the store.
|
||
resp = await client.get("/v1/sessions", params={"kind": "bogus"})
|
||
assert resp.status_code == 422
|
||
|
||
|
||
async def test_list_sessions_includes_title_and_status(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Each list item has title, status, labels, and timestamps."""
|
||
agent = await create_test_agent(client)
|
||
await _create_session(client, agent["id"], title="titled-session")
|
||
|
||
resp = await client.get("/v1/sessions")
|
||
data = resp.json()["data"]
|
||
assert len(data) >= 1
|
||
item = data[0]
|
||
assert "title" in item
|
||
assert "status" in item
|
||
assert "created_at" in item
|
||
assert "updated_at" in item
|
||
assert item["status"] in ("idle", "running", "failed")
|
||
|
||
|
||
async def test_list_sessions_includes_workspace_and_host_id(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions`` surfaces each session's ``workspace`` and
|
||
``host_id``.
|
||
|
||
The Web UI's new-session dialog reads ``workspace`` to warn when a
|
||
new session would share a working directory with an existing one.
|
||
These fields are populated from the conversation row; we bind a
|
||
host + workspace directly via the store (the create path requires a
|
||
live host tunnel to validate the workspace).
|
||
|
||
Covers both shapes:
|
||
- A bound session surfaces ``workspace`` + ``host_id``. A regression
|
||
that dropped ``workspace=conv.workspace`` from the list builder
|
||
would fail the value assertion below.
|
||
- An unbound session omits both keys (the route serializes with
|
||
``exclude_none``), which is the shape the UI reads as "not bound to
|
||
a directory" (``Conversation.workspace`` optional).
|
||
|
||
:param client: The test HTTP client.
|
||
:param db_uri: Per-test SQLite database URI, shared with the app's
|
||
store so a write here is visible to the route.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
bound = await _create_session(client, agent["id"], title="bound-session")
|
||
# Left unbound (no host/workspace) to pin the omitted-keys case.
|
||
unbound = await _create_session(client, agent["id"], title="unbound-session")
|
||
|
||
# Register the host first — conversations.host_id is an FK to the
|
||
# hosts table, so binding a non-existent host_id would fail the FK.
|
||
host = HostStore(db_uri).upsert_on_connect("host_test", "test-laptop", "owner@example.com")
|
||
# set_host_id writes host_id + workspace together (the
|
||
# ck_conversations_workspace_required_for_host constraint forbids a
|
||
# host_id with a NULL workspace).
|
||
SqlAlchemyConversationStore(db_uri).set_host_id(
|
||
bound["id"], host_id=host.host_id, workspace="/Users/me/repo"
|
||
)
|
||
|
||
resp = await client.get("/v1/sessions")
|
||
assert resp.status_code == 200
|
||
items_by_id = {s["id"]: s for s in resp.json()["data"]}
|
||
|
||
assert bound["id"] in items_by_id, (
|
||
f"bound session {bound['id']} missing from list; got {list(items_by_id)}"
|
||
)
|
||
bound_item = items_by_id[bound["id"]]
|
||
# Both must round-trip the stored row; workspace is the field the
|
||
# conflict hint depends on, host_id scopes the comparison per host.
|
||
assert bound_item["workspace"] == "/Users/me/repo"
|
||
assert bound_item["host_id"] == host.host_id
|
||
|
||
assert unbound["id"] in items_by_id, (
|
||
f"unbound session {unbound['id']} missing from list; got {list(items_by_id)}"
|
||
)
|
||
unbound_item = items_by_id[unbound["id"]]
|
||
# exclude_none drops unset fields entirely — not present, not null.
|
||
assert "workspace" not in unbound_item
|
||
assert "host_id" not in unbound_item
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"cached_status,expected_list_status",
|
||
[
|
||
("running", "running"),
|
||
# NO_DBOS path: parent turn parked on async-work drain.
|
||
# Sidebar treats this as "something is happening" — wire
|
||
# type doesn't carry "waiting" so it normalizes to "running".
|
||
("waiting", "running"),
|
||
("failed", "failed"),
|
||
("idle", "idle"),
|
||
],
|
||
)
|
||
async def test_list_sessions_reflects_relay_status_cache(
|
||
client: httpx.AsyncClient,
|
||
cached_status: str,
|
||
expected_list_status: str,
|
||
) -> None:
|
||
"""The list endpoint reads ``_session_status_cache`` so the sidebar
|
||
spinner reflects the runner-relayed live status, not the (mostly
|
||
empty) ``tasks`` table that the NO_DBOS path no longer writes to.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_module._session_status_cache[session["id"]] = cached_status
|
||
try:
|
||
resp = await client.get("/v1/sessions")
|
||
items = resp.json()["data"]
|
||
item = next(s for s in items if s["id"] == session["id"])
|
||
assert item["status"] == expected_list_status
|
||
finally:
|
||
sessions_module._session_status_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_list_sessions_rolls_up_busy_child_status(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions`` reports a parent row as running while any
|
||
direct sub-agent child is running.
|
||
|
||
The sidebar gets non-active row status from the list endpoint and
|
||
``WS /v1/sessions/updates``, not from ``child_sessions``. Seeding a
|
||
real child conversation plus the relay-fed status cache proves the
|
||
parent roll-up happens in the shared list-item builder and that an
|
||
unrelated sibling parent stays idle.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(client, agent["id"], title="parent")
|
||
other = await _create_session(client, agent["id"], title="other-parent")
|
||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||
child = conv_store.create_conversation(
|
||
kind="sub_agent",
|
||
title="coder:auth",
|
||
parent_conversation_id=parent["id"],
|
||
agent_id=agent["id"],
|
||
)
|
||
|
||
sessions_module._session_status_cache.pop(parent["id"], None)
|
||
sessions_module._session_status_cache.pop(other["id"], None)
|
||
sessions_module._session_status_cache[child.id] = "waiting"
|
||
try:
|
||
resp = await client.get("/v1/sessions")
|
||
assert resp.status_code == 200
|
||
items_by_id = {item["id"]: item for item in resp.json()["data"]}
|
||
|
||
assert items_by_id[parent["id"]]["status"] == "running"
|
||
assert items_by_id[other["id"]]["status"] == "idle"
|
||
finally:
|
||
sessions_module._session_status_cache.pop(parent["id"], None)
|
||
sessions_module._session_status_cache.pop(other["id"], None)
|
||
sessions_module._session_status_cache.pop(child.id, None)
|
||
|
||
|
||
async def test_session_snapshot_defaults_terminal_pending_false(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""A session with no entry in ``_session_terminal_pending_cache``
|
||
snapshots ``terminal_pending=False``.
|
||
|
||
This is the steady state for every non-terminal-first session and
|
||
for a terminal-first session once its terminal has landed — the
|
||
Web UI must not show a spinner. Asserting the concrete ``False``
|
||
(not just truthiness) proves the snapshot builder ships the field
|
||
with the right default rather than omitting it.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
sessions_module._session_terminal_pending_cache.pop(session["id"], None)
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
assert snap.json()["terminal_pending"] is False
|
||
|
||
|
||
async def test_session_snapshot_reflects_terminal_pending_cache(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""The GET snapshot reads ``_session_terminal_pending_cache`` so a
|
||
client connecting mid-spin-up sees ``terminal_pending=True``.
|
||
|
||
This is the reconnect channel: the Omnigent session stream has no replay
|
||
buffer, so a client that connects after the runner emitted the
|
||
pending event would otherwise miss it. Re-reading via GET proves
|
||
the value travels cache → response builder → snapshot.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_module._session_terminal_pending_cache[session["id"]] = True
|
||
try:
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
assert snap.json()["terminal_pending"] is True
|
||
finally:
|
||
sessions_module._session_terminal_pending_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_external_session_status_event_lands_in_status_cache(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Posting ``external_session_status`` (the claude-native forwarder's
|
||
only signal that a Claude turn is running) must update
|
||
``_session_status_cache`` so the list endpoint reflects it. Before
|
||
this fix the handler only published to the SSE pub-sub, leaving the
|
||
sidebar stuck on "idle" while the chat showed "Working…".
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
sessions_module._session_status_cache.pop(session["id"], None)
|
||
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_status",
|
||
"data": {"status": "running"},
|
||
},
|
||
)
|
||
# 202 Accepted (background) is the route's success contract;
|
||
# 200 OK acceptable too.
|
||
assert resp.status_code in (200, 202)
|
||
|
||
list_resp = await client.get("/v1/sessions")
|
||
item = next(s for s in list_resp.json()["data"] if s["id"] == session["id"])
|
||
assert item["status"] == "running"
|
||
finally:
|
||
sessions_module._session_status_cache.pop(session["id"], None)
|
||
|
||
|
||
# ── POST /v1/sessions/{id}/events external_session_superseded ─────
|
||
|
||
|
||
async def test_external_session_superseded_publishes_redirect_event(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Posting ``external_session_superseded`` republishes a
|
||
``session.superseded`` SSE event carrying the redirect target.
|
||
|
||
This is the claude-native forwarder's live-only redirect signal after
|
||
a Claude ``/clear``: a client viewing the old conversation follows to
|
||
the new one. The event is transient (not persisted) — the handler only
|
||
publishes to the session stream.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_superseded",
|
||
"data": {"target_conversation_id": "conv_new"},
|
||
},
|
||
)
|
||
assert resp.status_code in (200, 202)
|
||
|
||
superseded = [ev for _sid, ev in published if ev.get("type") == "session.superseded"]
|
||
assert len(superseded) == 1
|
||
event = superseded[0]
|
||
assert event["conversation_id"] == session["id"]
|
||
assert event["target_conversation_id"] == "conv_new"
|
||
assert event["reason"] == "clear"
|
||
|
||
|
||
async def test_external_session_superseded_requires_target(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""A superseded event without a target conversation id is rejected."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_superseded", "data": {}},
|
||
)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
async def test_external_session_superseded_drains_pending_inputs(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Superseding a session discards its unconsumed pending inputs.
|
||
|
||
The ``/clear`` the user typed in the web UI is recorded as a pending input
|
||
but never mirrored back (the session rotated away), so it would otherwise
|
||
re-hydrate as a stuck optimistic bubble on every reload of the old chat.
|
||
The superseded handler drains it (without committing it as a user message).
|
||
"""
|
||
from omnigent.runtime import pending_inputs
|
||
|
||
pending_inputs.reset_for_tests()
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
try:
|
||
# The optimistic pending entry the web composer recorded when the user
|
||
# sent ``/clear`` from the UI.
|
||
pending_inputs.record(session["id"], [{"type": "input_text", "text": "/clear"}])
|
||
assert pending_inputs.snapshot_for(session["id"]) != []
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_superseded",
|
||
"data": {"target_conversation_id": "conv_new"},
|
||
},
|
||
)
|
||
assert resp.status_code in (200, 202)
|
||
|
||
# Drained — so it won't reappear from the snapshot on reload, and it was
|
||
# NOT committed as a message item (the fresh session stays empty: no
|
||
# /clear bubble was promoted into history).
|
||
assert pending_inputs.snapshot_for(session["id"]) == []
|
||
items = (await client.get(f"/v1/sessions/{session['id']}/items")).json()["data"]
|
||
assert not any(item["type"] == "message" for item in items)
|
||
finally:
|
||
pending_inputs.reset_for_tests()
|
||
|
||
|
||
# ── POST /v1/sessions/{id}/events external_subagent_start ─────────
|
||
|
||
|
||
async def test_external_subagent_start_mints_child_session(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Posting ``external_subagent_start`` to a claude-native parent
|
||
creates a ``kind="sub_agent"`` child Conversation, returns its
|
||
id, and the child surfaces in
|
||
``GET /v1/sessions/{parent_id}/child_sessions``.
|
||
|
||
The forwarder uses this signal to register Claude Code's own
|
||
Task-tool subagents (which never POST to Omnigent themselves) so the
|
||
Subagents rail can show them.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": "a5c7effac5a9a35ab",
|
||
"agent_type": "Explore",
|
||
"description": "Trace the auth flow",
|
||
"tool_use_id": "toolu_016PwCnwmHv8h7kVpWLVd9YX",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code in (200, 202), f"unexpected status {resp.status_code}: {resp.text}"
|
||
body = resp.json()
|
||
child_id = body["child_session_id"]
|
||
assert child_id.startswith("conv_")
|
||
assert body["queued"] is False
|
||
|
||
# The child should appear under the parent in child_sessions.
|
||
# Fields proven elsewhere; here we just pin the linkage + identity.
|
||
children_resp = await client.get(f"/v1/sessions/{parent['id']}/child_sessions")
|
||
children = children_resp.json()["data"]
|
||
matching = [c for c in children if c["id"] == child_id]
|
||
assert len(matching) == 1, f"child {child_id} not in {children!r}"
|
||
child = matching[0]
|
||
assert child["parent_session_id"] == parent["id"]
|
||
assert child["kind"] == "sub_agent"
|
||
# Title format mirrors omnigent-spawned children (``tool:name``).
|
||
# The session_name half encodes ``subagent_id`` so two children
|
||
# with the same agent_type + description don't collide on the
|
||
# ``(parent, title)`` unique index (the LLM routinely emits the
|
||
# same description for parallel Task spawns).
|
||
assert child["tool"] == "Explore"
|
||
assert child["session_name"] == "a5c7effac5a9a35ab"
|
||
# Description is preserved on the row's labels for surfaces that
|
||
# want it; the rail's row UI ignores ``session_name``.
|
||
assert child["labels"]["omnigent.claude_native.description"] == "Trace the auth flow"
|
||
|
||
|
||
async def test_external_subagent_start_handles_duplicate_agent_type_and_description(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Two distinct sub-agents with the same ``agent_type`` +
|
||
``description`` (but different ``subagent_id``) must both
|
||
register successfully. Claude's Task tool routinely spawns
|
||
parallel sub-agents with identical ``agentType`` /
|
||
``description`` strings (e.g. three "general-purpose: Tell a
|
||
joke"), and a naïve ``title = f"{agent_type}:{description}"``
|
||
collides on the conversation store's
|
||
``(parent_conversation_id, title)`` unique index. Pinning this
|
||
case so the title format stays per-sub-agent-id-unique.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
|
||
common_data = {
|
||
"agent_type": "general-purpose",
|
||
"description": "Tell a joke",
|
||
}
|
||
first = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
**common_data,
|
||
"subagent_id": "a03186614301289fb",
|
||
"tool_use_id": "toolu_first",
|
||
},
|
||
},
|
||
)
|
||
second = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
**common_data,
|
||
"subagent_id": "aefb9a13a81715740",
|
||
"tool_use_id": "toolu_second",
|
||
},
|
||
},
|
||
)
|
||
assert first.status_code in (200, 202), f"first registration failed: {first.text}"
|
||
assert second.status_code in (200, 202), f"second registration failed: {second.text}"
|
||
first_id = first.json()["child_session_id"]
|
||
second_id = second.json()["child_session_id"]
|
||
assert first_id != second_id
|
||
|
||
# Both children appear under the parent.
|
||
children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"]
|
||
child_ids = {c["id"] for c in children}
|
||
assert {first_id, second_id} <= child_ids
|
||
|
||
|
||
async def test_external_subagent_start_is_idempotent_on_subagent_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Two POSTs carrying the same ``subagent_id`` resolve to the same
|
||
child row. The forwarder retries on transient HTTP errors so the
|
||
handler must NOT mint a duplicate when the cursor hasn't yet
|
||
been written.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
payload = {
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": "a5c7effac5a9a35ab",
|
||
"agent_type": "Explore",
|
||
"description": "Trace the auth flow",
|
||
"tool_use_id": "toolu_016PwCnwmHv8h7kVpWLVd9YX",
|
||
},
|
||
}
|
||
|
||
first = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload)
|
||
second = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload)
|
||
assert first.json()["child_session_id"] == second.json()["child_session_id"]
|
||
|
||
children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"]
|
||
# Pin "exactly one" rather than ">= 1" — a duplicate would slip
|
||
# past >= without a failure here.
|
||
assert len(children) == 1
|
||
|
||
|
||
async def test_external_subagent_start_adopts_unlabeled_title_collision(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Redelivery adopts (and heals) an existing child row that carries the
|
||
colliding title but no ``subagent_id`` label.
|
||
|
||
This is the production wedge behind recurring POST /events 500s: an
|
||
earlier registration died between ``create_conversation`` and
|
||
``set_labels``, leaving a row the label-based idempotency lookup
|
||
can't see. Every forwarder retry then tripped the
|
||
``(parent, title)`` unique index, the unhandled
|
||
``NameAlreadyExistsError`` became a 500, and after retry exhaustion
|
||
the forwarder parked the sub-agent — it silently never appeared in
|
||
the rail. The handler must adopt the row by title, re-stamp its
|
||
labels, and return its id instead.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
|
||
# Seed the wedge state through the public create endpoint: a
|
||
# ``sub_agent`` child carrying the exact collision title but none of
|
||
# the claude-native labels — exactly what a crash between
|
||
# ``create_conversation`` and ``set_labels`` leaves behind.
|
||
seeded = await client.post(
|
||
"/v1/sessions",
|
||
json={
|
||
"agent_id": agent["id"],
|
||
"parent_session_id": parent["id"],
|
||
"title": "Explore:a5c7effac5a9a35ab",
|
||
},
|
||
)
|
||
assert seeded.status_code == 201, seeded.text
|
||
seeded_id = seeded.json()["id"]
|
||
|
||
payload = {
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": "a5c7effac5a9a35ab",
|
||
"agent_type": "Explore",
|
||
"description": "Trace the auth flow",
|
||
"tool_use_id": "toolu_016PwCnwmHv8h7kVpWLVd9YX",
|
||
},
|
||
}
|
||
resp = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload)
|
||
# 200/202, NOT 500 — the duplicate-title collision must be adopted,
|
||
# not escape as an unhandled NameAlreadyExistsError.
|
||
assert resp.status_code in (200, 202), f"unexpected status {resp.status_code}: {resp.text}"
|
||
assert resp.json()["child_session_id"] == seeded_id
|
||
|
||
children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"]
|
||
# Exactly one child: adoption must not mint a sibling row.
|
||
assert len(children) == 1
|
||
child = children[0]
|
||
assert child["id"] == seeded_id
|
||
# Labels are healed so the NEXT delivery resolves via the fast
|
||
# label lookup instead of re-tripping the unique index.
|
||
assert child["labels"]["omnigent.claude_native.subagent_id"] == "a5c7effac5a9a35ab"
|
||
assert child["labels"]["omnigent.claude_native.description"] == "Trace the auth flow"
|
||
|
||
# Redelivery now takes the label-lookup path to the same id.
|
||
again = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload)
|
||
assert again.json()["child_session_id"] == seeded_id
|
||
|
||
|
||
async def test_external_subagent_start_idempotency_pages_beyond_first_100(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Idempotency must page through all children, not just the newest 100.
|
||
A parent with > 100 sub-agents that retries an older ``subagent_id``
|
||
would otherwise miss the existing labeled row and trip the
|
||
``(parent, title)`` unique constraint on re-create.
|
||
|
||
We mint the older sub-agent first, push more than ``limit`` newer
|
||
ones on top, then re-post the older payload and assert the same
|
||
child id comes back (one row total for that id).
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
|
||
older = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": "older-subagent",
|
||
"agent_type": "Explore",
|
||
"description": "First one",
|
||
"tool_use_id": "toolu_older",
|
||
},
|
||
},
|
||
)
|
||
older_child_id = older.json()["child_session_id"]
|
||
|
||
# 100 mirrors the page limit; push enough to require a second page.
|
||
for index in range(101):
|
||
await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": f"newer-{index}",
|
||
"agent_type": "Explore",
|
||
"description": f"Newer {index}",
|
||
"tool_use_id": f"toolu_newer_{index}",
|
||
},
|
||
},
|
||
)
|
||
|
||
repeat = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_subagent_start",
|
||
"data": {
|
||
"subagent_id": "older-subagent",
|
||
"agent_type": "Explore",
|
||
"description": "First one",
|
||
"tool_use_id": "toolu_older",
|
||
},
|
||
},
|
||
)
|
||
# Without pagination the older sub-agent would fall off the first
|
||
# 100 results, the handler would fall through to ``create_conversation``,
|
||
# and the ``(parent, title)`` unique constraint would either fail the
|
||
# request or mint a fresh id. Either way ``child_session_id`` would
|
||
# diverge from the original.
|
||
assert repeat.json()["child_session_id"] == older_child_id
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"missing_key",
|
||
["subagent_id", "agent_type", "description", "tool_use_id"],
|
||
)
|
||
async def test_external_subagent_start_rejects_missing_required_keys(
|
||
client: httpx.AsyncClient,
|
||
missing_key: str,
|
||
) -> None:
|
||
"""
|
||
A POST missing any of the four required ``data`` keys returns
|
||
400 — payload validation is at the route boundary so the handler
|
||
body always sees a complete record.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
data = {
|
||
"subagent_id": "a5c7effac5a9a35ab",
|
||
"agent_type": "Explore",
|
||
"description": "Trace the auth flow",
|
||
"tool_use_id": "toolu_016PwCnwmHv8h7kVpWLVd9YX",
|
||
}
|
||
data.pop(missing_key)
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={"type": "external_subagent_start", "data": data},
|
||
)
|
||
assert resp.status_code == 400
|
||
# Error message must name the missing field so the forwarder's
|
||
# logs point at the bug rather than reading "Invalid payload".
|
||
assert missing_key in resp.text
|
||
|
||
|
||
# ── POST /v1/sessions/{id}/events skill slash_command ─────
|
||
|
||
|
||
async def test_skill_slash_command_persists_visible_item_and_hidden_meta_message(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Structured skill slash commands persist two durable records.
|
||
|
||
The visible ``slash_command`` item is what UI/TUI transcripts
|
||
render. The hidden ``message`` item carries the full skill body
|
||
with ``is_meta=True`` so runner resume/history replay still has
|
||
the skill context without showing raw instructions to users.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
forwarded: list[dict[str, Any]] = []
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""
|
||
Emulate the runner: resolve skills and capture event posts.
|
||
|
||
Skill content is runner-owned, so the server resolves the
|
||
``<skill>`` meta text via ``POST /skills/resolve`` before
|
||
persisting/forwarding. This fake mirrors that endpoint with the
|
||
real :func:`format_skill_meta_text`, then captures the forwarded
|
||
turn input on ``/events``.
|
||
|
||
:param request: Request sent to the fake runner.
|
||
:returns: Resolved meta text (``/skills/resolve``) or an
|
||
accepted response (``/events``).
|
||
"""
|
||
if request.method == "POST" and request.url.path.endswith("/skills/resolve"):
|
||
payload = json.loads(request.content)
|
||
skill = SkillSpec(
|
||
name="grill-me",
|
||
description="Stress-test a plan.",
|
||
content="Ask sharp questions one at a time.",
|
||
)
|
||
return httpx.Response(
|
||
200,
|
||
json={"meta_text": format_skill_meta_text(skill, payload.get("arguments", ""))},
|
||
)
|
||
if request.method == "POST" and request.url.path.endswith("/events"):
|
||
forwarded.append(json.loads(request.content))
|
||
return httpx.Response(202, json={"queued": True})
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient:
|
||
"""
|
||
Resolve every session to the fake runner client.
|
||
|
||
:param session_id: Session id being routed.
|
||
:param runner_router: Real app runner router, unused here.
|
||
:returns: The fake runner client.
|
||
"""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
try:
|
||
agent = await create_test_agent(
|
||
client,
|
||
name="skill-agent",
|
||
skills=[
|
||
{
|
||
"name": "grill-me",
|
||
"description": "Stress-test a plan.",
|
||
"content": "Ask sharp questions one at a time.",
|
||
}
|
||
],
|
||
)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "slash_command",
|
||
"data": {
|
||
"kind": "skill",
|
||
"name": "grill-me",
|
||
"arguments": "review this rollout",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
finally:
|
||
await fake_runner.aclose()
|
||
|
||
assert resp.json()["queued"] is True
|
||
assert resp.json()["item_id"].startswith("sc_")
|
||
|
||
items_resp = await client.get(f"/v1/sessions/{session['id']}/items")
|
||
assert items_resp.status_code == 200, items_resp.text
|
||
items = items_resp.json()["data"]
|
||
visible = next(item for item in items if item["type"] == "slash_command")
|
||
meta = next(item for item in items if item["type"] == "message" and item.get("is_meta"))
|
||
assert visible["name"] == "grill-me"
|
||
assert visible["kind"] == "skill"
|
||
assert visible["arguments"] == "review this rollout"
|
||
assert visible["response_id"] == meta["response_id"]
|
||
text = meta["content"][0]["text"]
|
||
assert "<skill>" in text
|
||
assert "<name>grill-me</name>" in text
|
||
assert "Ask sharp questions one at a time." in text
|
||
assert "<user_request>\nreview this rollout\n</user_request>" in text
|
||
assert "Use the load_skill tool" not in text
|
||
|
||
assert forwarded == [
|
||
{
|
||
"type": "message",
|
||
"role": "user",
|
||
"content": meta["content"],
|
||
"agent_id": agent["id"],
|
||
"model": "skill-agent",
|
||
"has_mcp_servers": False,
|
||
# The forwarded message is the meta item; its store id lets the
|
||
# runner dedup it on a cold-cache history reload.
|
||
"persisted_item_id": meta["id"],
|
||
}
|
||
]
|
||
event_types = [event["type"] for _, event in published]
|
||
assert event_types == ["response.output_item.done"]
|
||
assert published[0][1]["item"]["type"] == "slash_command"
|
||
assert published[0][1]["item"]["id"] == visible["id"]
|
||
|
||
# The dispatch also seeds the sidebar title from the typed command,
|
||
# mirroring the plain-message path. Without it, a session whose FIRST
|
||
# message is a skill invocation (web landing composer, REPL) keeps a
|
||
# NULL title and the UI falls back to the conversation id. The title
|
||
# must come from the visible "/name args" text — the hidden meta
|
||
# item's SKILL.md blob leaking here would show skill instructions in
|
||
# the sidebar.
|
||
session_resp = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert session_resp.status_code == 200, session_resp.text
|
||
assert session_resp.json()["title"] == "/grill-me review this rollout"
|
||
|
||
|
||
async def test_skill_slash_command_keeps_existing_title(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Skill title seeding fills only the empty slot.
|
||
|
||
A session that already has a title (user-assigned at create, or
|
||
seeded from an earlier first message) must keep it when a skill is
|
||
invoked later — otherwise every mid-conversation ``/skill`` send
|
||
would rename the session in the sidebar.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""
|
||
Emulate the runner: resolve the skill, accept event posts.
|
||
|
||
:param request: Request sent to the fake runner.
|
||
:returns: Resolved meta text (``/skills/resolve``) or an
|
||
accepted response (``/events``).
|
||
"""
|
||
if request.method == "POST" and request.url.path.endswith("/skills/resolve"):
|
||
skill = SkillSpec(
|
||
name="grill-me",
|
||
description="Stress-test a plan.",
|
||
content="Ask sharp questions one at a time.",
|
||
)
|
||
return httpx.Response(200, json={"meta_text": format_skill_meta_text(skill, "")})
|
||
return httpx.Response(202, json={"queued": True})
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient:
|
||
"""
|
||
Resolve every session to the fake runner client.
|
||
|
||
:param session_id: Session id being routed.
|
||
:param runner_router: Real app runner router, unused here.
|
||
:returns: The fake runner client.
|
||
"""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
try:
|
||
agent = await create_test_agent(
|
||
client,
|
||
name="skill-agent-titled",
|
||
skills=[
|
||
{
|
||
"name": "grill-me",
|
||
"description": "Stress-test a plan.",
|
||
"content": "Ask sharp questions one at a time.",
|
||
}
|
||
],
|
||
)
|
||
session = await _create_session(client, agent["id"], title="my rollout session")
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "slash_command",
|
||
"data": {"kind": "skill", "name": "grill-me", "arguments": ""},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
finally:
|
||
await fake_runner.aclose()
|
||
|
||
session_resp = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert session_resp.status_code == 200, session_resp.text
|
||
# Unchanged: the seed is a no-op when a title exists. "/grill-me"
|
||
# appearing here would mean the skill path overwrites user titles.
|
||
assert session_resp.json()["title"] == "my rollout session"
|
||
|
||
|
||
async def test_skill_slash_command_non_json_resolve_surfaces_controlled_error(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A non-JSON ``/skills/resolve`` body (e.g. an HTML error page injected
|
||
by a proxy) surfaces as a controlled ``OmnigentError`` (HTTP 500
|
||
with our message), not an uncaught crash with the generic
|
||
"An internal error occurred." body.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Return a non-JSON body for resolve; 202 otherwise."""
|
||
if request.url.path.endswith("/skills/resolve"):
|
||
return httpx.Response(200, content=b"<html>not json</html>")
|
||
return httpx.Response(202, json={"queued": True})
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(session_id: str, runner_router: object) -> httpx.AsyncClient:
|
||
"""Resolve every session to the fake runner."""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
try:
|
||
agent = await create_test_agent(
|
||
client,
|
||
name="skill-agent-badjson",
|
||
skills=[{"name": "grill-me", "description": "Stress-test a plan.", "content": "Ask."}],
|
||
)
|
||
session = await _create_session(client, agent["id"])
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "slash_command",
|
||
"data": {"kind": "skill", "name": "grill-me", "arguments": ""},
|
||
},
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
|
||
assert resp.status_code == 500, resp.text
|
||
# Our controlled message, not the generic "An internal error occurred."
|
||
assert "malformed skill resolution" in resp.json()["error"]["message"]
|
||
|
||
|
||
async def test_external_meta_user_message_persists_without_live_input_event(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
External bridge meta messages are durable but hidden from live UI.
|
||
|
||
Codex-native mirrors ``<skill>`` wrappers via
|
||
``external_conversation_item``. The server must store those
|
||
messages so resume has the context, while suppressing
|
||
``session.input.consumed`` so subscribers do not render raw skill
|
||
text.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "<skill>hidden</skill>"}],
|
||
"is_meta": True,
|
||
},
|
||
"response_id": "codex_turn_123",
|
||
"source_id": "codex-skill-meta",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
items = (await client.get(f"/v1/sessions/{session['id']}/items")).json()["data"]
|
||
meta = next(item for item in items if item["type"] == "message")
|
||
assert meta["is_meta"] is True
|
||
assert meta["content"][0]["text"] == "<skill>hidden</skill>"
|
||
assert published == []
|
||
|
||
|
||
async def test_external_user_message_folds_pending_image_into_durable_item(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Regression: a native web message's image survives in durable history.
|
||
|
||
The native transcript mirrors a user message back text-only
|
||
(``external_conversation_item`` with no image block), so the image —
|
||
which the client uploaded and POSTed — would be dropped from
|
||
conversation history and vanish on every reload. The pending-input
|
||
entry recorded at web-POST time still carries the image block (with
|
||
its real ``file_id``); when the transcript item persists, the server
|
||
folds that block into the durable item.
|
||
|
||
Here we seed the pending entry directly (standing in for the prior
|
||
native web POST), then post the text-only transcript mirror and
|
||
assert the persisted item carries BOTH the image block and the text.
|
||
Without the fold, the stored item is text-only and the image is gone.
|
||
"""
|
||
from omnigent.runtime import pending_inputs
|
||
|
||
pending_inputs.reset_for_tests()
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# The optimistic pending entry the web composer recorded on POST:
|
||
# image (real upload id) + caption text.
|
||
pending_inputs.record(
|
||
session["id"],
|
||
[
|
||
{"type": "input_image", "file_id": "file_real_99", "filename": "diagram.png"},
|
||
{"type": "input_text", "text": "explain this diagram"},
|
||
],
|
||
)
|
||
try:
|
||
# The transcript forwarder mirrors the message back TEXT-ONLY.
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [
|
||
{
|
||
"type": "input_text",
|
||
"text": "[Attached: /tmp/diagram.png]\n\nexplain this diagram",
|
||
}
|
||
],
|
||
},
|
||
"response_id": "native_turn_1",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
items = (await client.get(f"/v1/sessions/{session['id']}/items")).json()["data"]
|
||
user_msg = next(item for item in items if item["type"] == "message")
|
||
# The image block was folded back in, ahead of the transcript text
|
||
# — so reloading history shows the image, not just the caption.
|
||
assert user_msg["content"][0] == {
|
||
"type": "input_image",
|
||
"file_id": "file_real_99",
|
||
"filename": "diagram.png",
|
||
}
|
||
expected_text = "[Attached: /tmp/diagram.png]\n\nexplain this diagram"
|
||
assert user_msg["content"][1]["text"] == expected_text
|
||
# The pending entry was drained — it won't double-render on rebind.
|
||
assert pending_inputs.snapshot_for(session["id"]) == []
|
||
finally:
|
||
pending_inputs.reset_for_tests()
|
||
|
||
|
||
async def test_external_user_message_drain_publishes_cleared_pending_id(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Draining a pending entry publishes its id on session.input.consumed.
|
||
|
||
The client's drop-by-id promotion depends on the server echoing the
|
||
drained entry's id as ``cleared_pending_id`` on the consumed event.
|
||
This pins that wire contract end-to-end: record a pending entry,
|
||
post the (text-only) transcript mirror, and assert the published
|
||
``session.input.consumed`` carries ``cleared_pending_id`` equal to
|
||
the recorded id. A regression here silently breaks the bubble swap
|
||
(the client falls back to FIFO and the entry can strand).
|
||
"""
|
||
from omnigent.runtime import pending_inputs
|
||
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
pending_inputs.reset_for_tests()
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
pid = pending_inputs.record(session["id"], [{"type": "input_text", "text": "hi there"}])
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "hi there"}],
|
||
},
|
||
"response_id": "native_turn_1",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
consumed = [ev for _sid, ev in published if ev.get("type") == "session.input.consumed"]
|
||
assert len(consumed) == 1, f"expected one consumed event, got {published}"
|
||
# The drained entry's id is echoed so the client drops that bubble by id.
|
||
assert consumed[0]["data"]["cleared_pending_id"] == pid
|
||
# And the entry is gone from the index.
|
||
assert pending_inputs.snapshot_for(session["id"]) == []
|
||
finally:
|
||
pending_inputs.reset_for_tests()
|
||
|
||
|
||
# ── PATCH /v1/sessions/{id} ─────────────────────────────
|
||
|
||
|
||
async def test_patch_session_updates_title(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH updates title and returns the updated snapshot."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"], title="old")
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"title": "new title"},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["title"] == "new title"
|
||
|
||
|
||
async def test_patch_session_updates_labels(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH upserts labels (merges, doesn't replace)."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"a": "1"},
|
||
)
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"labels": {"b": "2"}},
|
||
)
|
||
assert resp.status_code == 200
|
||
labels = resp.json()["labels"]
|
||
assert labels["a"] == "1"
|
||
assert labels["b"] == "2"
|
||
|
||
|
||
async def test_patch_session_archive_hides_from_default_list(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Archiving via PATCH drops the session from the default
|
||
``GET /v1/sessions`` list and surfaces it only behind
|
||
``include_archived=true``; unarchiving restores it.
|
||
|
||
This is the full round-trip the sidebar depends on. A failure on
|
||
the "hidden" assertion means archived sessions leak into the
|
||
default view; a failure on the "include_archived" assertion means
|
||
the toggle can never surface them; a failure on the restore means
|
||
unarchive is broken.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"], title="to-archive")
|
||
sid = session["id"]
|
||
|
||
# Archive it. The PATCH response reflects the new state.
|
||
resp = await client.patch(f"/v1/sessions/{sid}", json={"archived": True})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["archived"] is True
|
||
|
||
# Default listing excludes it.
|
||
default_ids = {s["id"] for s in (await client.get("/v1/sessions")).json()["data"]}
|
||
assert sid not in default_ids, "archived session must be hidden from the default list"
|
||
|
||
# include_archived=true surfaces it, flagged archived.
|
||
archived_list = (await client.get("/v1/sessions", params={"include_archived": "true"})).json()[
|
||
"data"
|
||
]
|
||
archived_row = next((s for s in archived_list if s["id"] == sid), None)
|
||
assert archived_row is not None, "include_archived=true must return the archived session"
|
||
assert archived_row["archived"] is True, "list item must carry archived=true"
|
||
|
||
# Unarchive restores it to the default list.
|
||
resp = await client.patch(f"/v1/sessions/{sid}", json={"archived": False})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["archived"] is False
|
||
default_ids_after = {s["id"] for s in (await client.get("/v1/sessions")).json()["data"]}
|
||
assert sid in default_ids_after, "unarchived session must reappear in the default list"
|
||
|
||
|
||
@pytest.mark.parametrize("reasoning_effort", ["high", "xhigh", "max"])
|
||
async def test_patch_session_updates_reasoning_effort(
|
||
client: httpx.AsyncClient,
|
||
reasoning_effort: str,
|
||
) -> None:
|
||
"""PATCH sets reasoning_effort on the session."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": reasoning_effort},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["reasoning_effort"] == reasoning_effort
|
||
|
||
|
||
async def test_create_session_sets_terminal_launch_args(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
JSON ``POST /v1/sessions`` persists terminal_launch_args, and the
|
||
value round-trips through a later GET snapshot.
|
||
|
||
This is the web permission-mode path: the New Chat dialog sends
|
||
``["--permission-mode", "bypassPermissions"]`` at create time so the
|
||
runner has it on the session row before it auto-launches the
|
||
terminal. Re-reading via GET (not just the create echo) proves the
|
||
value travelled route → create_conversation → DB → response
|
||
builder. The two-token list also exercises multi-element ordering.
|
||
Before this change the JSON create path dropped the field entirely,
|
||
so the GET would carry ``None`` and this would fail.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
terminal_launch_args=["--permission-mode", "bypassPermissions"],
|
||
)
|
||
# Create echo carries the persisted args.
|
||
assert session["terminal_launch_args"] == ["--permission-mode", "bypassPermissions"]
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
# Round-trip from the DB confirms it wasn't only on the in-memory echo,
|
||
# with order preserved (a reordered pair would break the CLI invocation).
|
||
assert snap.json()["terminal_launch_args"] == ["--permission-mode", "bypassPermissions"]
|
||
|
||
|
||
async def test_create_session_without_terminal_launch_args_is_null(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Omitting terminal_launch_args on JSON create leaves the column
|
||
NULL — a non-native / auto-mode-off session stores nothing.
|
||
|
||
A non-None value here would mean the create path invented args,
|
||
which would silently change how the runner launches the terminal.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
assert session["terminal_launch_args"] is None
|
||
|
||
|
||
async def test_create_session_rejects_oversized_terminal_launch_args(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
JSON create rejects a terminal_launch_args list past the count cap.
|
||
|
||
Pins the same server-side bound (``_MAX_TERMINAL_LAUNCH_ARGS``)
|
||
that the PATCH path enforces, on the create path too. A success
|
||
here would mean the create route skipped the shared validator,
|
||
letting a caller bloat a session row arbitrarily.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
# 257 entries: one past the 256 cap, so the count check fires.
|
||
resp = await client.post(
|
||
"/v1/sessions",
|
||
json={"agent_id": agent["id"], "terminal_launch_args": ["--x"] * 257},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "terminal_launch_args" in resp.text
|
||
|
||
|
||
async def test_patch_session_sets_terminal_launch_args(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
PATCH persists terminal_launch_args and it surfaces in a later
|
||
GET snapshot.
|
||
|
||
Re-reading via GET (not just trusting the PATCH echo) proves the
|
||
value travelled route → store → DB → response builder. If the
|
||
builder dropped the field or the store didn't persist it, the GET
|
||
snapshot would carry ``None`` and this would fail — the exact gap
|
||
that would leave a daemon-launched runner with no args.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"terminal_launch_args": ["--dangerously-skip-permissions", "--model", "opus"]},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["terminal_launch_args"] == [
|
||
"--dangerously-skip-permissions",
|
||
"--model",
|
||
"opus",
|
||
]
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
assert snap.json()["terminal_launch_args"] == [
|
||
"--dangerously-skip-permissions",
|
||
"--model",
|
||
"opus",
|
||
]
|
||
|
||
|
||
async def test_patch_session_replaces_terminal_launch_args(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A second PATCH replaces terminal_launch_args wholesale rather than
|
||
appending — the resume last-write-wins contract
|
||
(designs/NATIVE_RUNNER_SERVER_LAUNCH.md).
|
||
|
||
If the route/store appended, the final value would be
|
||
``["--model", "opus", "--verbose"]`` and this would fail — which
|
||
is the bug that would make repeated resumes accumulate stale
|
||
flags.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
first = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"terminal_launch_args": ["--model", "opus"]},
|
||
)
|
||
assert first.status_code == 200
|
||
|
||
second = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"terminal_launch_args": ["--verbose"]},
|
||
)
|
||
assert second.status_code == 200
|
||
assert second.json()["terminal_launch_args"] == ["--verbose"]
|
||
|
||
|
||
async def test_patch_session_rejects_oversized_terminal_launch_args(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
PATCH rejects a terminal_launch_args list past the count cap with
|
||
a 400.
|
||
|
||
This pins the server-side bound (``_MAX_TERMINAL_LAUNCH_ARGS``):
|
||
without it a caller could bloat a single session row arbitrarily.
|
||
A 200 here would mean the validator was bypassed.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# 257 entries: one past the 256 cap, so the count check fires.
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"terminal_launch_args": ["--x"] * 257},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "terminal_launch_args" in resp.text
|
||
|
||
|
||
async def test_patch_session_clears_extended_reasoning_effort(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH clear aliases clear an extended reasoning_effort value."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
seed = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "max"},
|
||
)
|
||
assert seed.status_code == 200
|
||
assert seed.json()["reasoning_effort"] == "max"
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "default"},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["reasoning_effort"] is None
|
||
|
||
|
||
async def test_patch_session_rejects_invalid_reasoning_effort(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
PATCH with an unsupported ``reasoning_effort`` value fails loud.
|
||
|
||
The route validates the body against
|
||
``_SESSION_METADATA_REASONING_EFFORTS`` before any DB write, so the
|
||
request is rejected with a typed 400 and the session row is left
|
||
unchanged. Mirrors the POST-create validation contract — the two
|
||
write paths must reject the same set of values.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
# Seed a known prior value so we can prove the row was untouched
|
||
# by the rejected PATCH.
|
||
seed = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "low"},
|
||
)
|
||
assert seed.status_code == 200
|
||
assert seed.json()["reasoning_effort"] == "low"
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "bogus"},
|
||
)
|
||
# 400 + typed code: required by DESIGN_PRINCIPLES.md §5 "fail
|
||
# loud." If this fails with 200, the route silently persisted
|
||
# an unsupported value.
|
||
assert resp.status_code == 400, resp.text
|
||
error = resp.json()["error"]
|
||
assert error["code"] == "invalid_input"
|
||
# Message surfaces the offending value and the supported set so
|
||
# the caller can fix the request without reading server logs.
|
||
assert "bogus" in error["message"]
|
||
assert "none, minimal, low, medium, high, xhigh, max" in error["message"]
|
||
|
||
# The DB row keeps the prior value — validation runs before any
|
||
# write. If this assertion fails, the route mutated state on a
|
||
# rejected request (partial-update bug).
|
||
after = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert after.status_code == 200
|
||
assert after.json()["reasoning_effort"] == "low"
|
||
|
||
|
||
async def test_patch_session_updates_multiple_fields(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH updates title, labels, and effort together."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={
|
||
"title": "updated",
|
||
"labels": {"k": "v"},
|
||
"reasoning_effort": "low",
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["title"] == "updated"
|
||
assert body["labels"]["k"] == "v"
|
||
assert body["reasoning_effort"] == "low"
|
||
|
||
|
||
async def test_patch_session_404_for_nonexistent(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH returns 404 for a session that doesn't exist."""
|
||
resp = await client.patch(
|
||
"/v1/sessions/conv_nonexistent",
|
||
json={"title": "nope"},
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
|
||
async def test_patch_session_sets_external_session_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""PATCH persists external_session_id and returns it in the snapshot."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "a1b2c3d4-1234-5678-9abc-def012345678"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
# Response body is the updated snapshot — the wrapper bridge
|
||
# reads this directly without a follow-up GET. If this fails
|
||
# the route built the response from a stale entity.
|
||
assert resp.json()["external_session_id"] == "a1b2c3d4-1234-5678-9abc-def012345678"
|
||
|
||
after = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert after.status_code == 200
|
||
# Independent GET proves the value was persisted (not just held
|
||
# in the PATCH response).
|
||
assert after.json()["external_session_id"] == "a1b2c3d4-1234-5678-9abc-def012345678"
|
||
|
||
|
||
async def test_patch_session_external_session_id_idempotent_same_value(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Writing the same external_session_id twice is a no-op (200, no error)."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
first = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "sid-1"},
|
||
)
|
||
assert first.status_code == 200, first.text
|
||
second = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "sid-1"},
|
||
)
|
||
# Both writes succeed; the second is a server-side no-op. If
|
||
# this returned 400 we'd be tripping the overwrite guard on
|
||
# legitimate re-mirrors (e.g. server bounce + fresh forwarder
|
||
# process).
|
||
assert second.status_code == 200, second.text
|
||
assert second.json()["external_session_id"] == "sid-1"
|
||
|
||
|
||
async def test_patch_session_external_session_id_rejects_overwrite(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Overwriting an already-set external_session_id fails 400.
|
||
|
||
The store raises ValueError on overwrite; the route translates
|
||
it to ``invalid_input``. The original value must survive — a
|
||
silent overwrite would destroy the mapping the original
|
||
wrapper bridge captured and make ``--resume`` unable to recover
|
||
the prior external transcript.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
seed = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "sid-1"},
|
||
)
|
||
assert seed.status_code == 200, seed.text
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "sid-2"},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
error = resp.json()["error"]
|
||
assert error["code"] == "invalid_input"
|
||
# Message names both ids so the caller's logs make the conflict
|
||
# obvious without reading server-side state.
|
||
assert "sid-1" in error["message"]
|
||
assert "sid-2" in error["message"]
|
||
|
||
after = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert after.status_code == 200
|
||
# First-writer-wins after the rejected PATCH.
|
||
assert after.json()["external_session_id"] == "sid-1"
|
||
|
||
|
||
async def test_create_session_returns_null_external_session_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A freshly created session has external_session_id = null.
|
||
|
||
Wrapper bridges depend on this default to tell "not yet
|
||
observed" from "set to empty" — a non-null default would mean
|
||
the bridge can't distinguish those states.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["external_session_id"] is None
|
||
|
||
|
||
async def test_list_sessions_includes_external_session_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""List items expose external_session_id so the sidebar can badge runtime."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"external_session_id": "sid-1"},
|
||
)
|
||
|
||
resp = await client.get("/v1/sessions")
|
||
assert resp.status_code == 200
|
||
data = resp.json()["data"]
|
||
found = next((row for row in data if row["id"] == session["id"]), None)
|
||
assert found is not None, f"created session not in list: {data}"
|
||
# Without this field on SessionListItem the picker/sidebar
|
||
# would need a follow-up GET per row to know the runtime kind.
|
||
assert found["external_session_id"] == "sid-1"
|
||
|
||
|
||
# ── claude-native session discovery (list + snapshot) ────────────
|
||
|
||
|
||
async def test_claude_native_session_discoverable_with_terminal_metadata(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""A claude-native session exposes the full identity bundle the Web
|
||
UI needs to find and open it — on both the list and the snapshot."""
|
||
agent = await create_test_agent(client, name="claude-native-ui")
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
title="universe @ lakebox",
|
||
# Labels the route's _is_claude_native_terminal_session and the
|
||
# UI's terminal-first layout key off of.
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
session_id = session["id"]
|
||
|
||
# Forwarder mirrors Claude's runtime session uuid once Claude starts.
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session_id}",
|
||
json={"external_session_id": "11111111-2222-3333-4444-555555555555"},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
|
||
# Sidebar list surface.
|
||
list_resp = await client.get("/v1/sessions")
|
||
assert list_resp.status_code == 200
|
||
row = next((r for r in list_resp.json()["data"] if r["id"] == session_id), None)
|
||
assert row is not None, f"session not in list: {list_resp.json()}"
|
||
assert row["title"] == "universe @ lakebox"
|
||
assert row["agent_name"] == "claude-native-ui"
|
||
assert row["labels"]["omnigent.wrapper"] == "claude-code-native-ui"
|
||
assert row["labels"]["omnigent.ui"] == "terminal"
|
||
assert row["external_session_id"] == "11111111-2222-3333-4444-555555555555"
|
||
assert row["status"] in ("idle", "running", "failed")
|
||
|
||
# Snapshot surface (opening the session) carries the same bundle.
|
||
snap = (await client.get(f"/v1/sessions/{session_id}")).json()
|
||
assert snap["title"] == "universe @ lakebox"
|
||
assert snap["agent_name"] == "claude-native-ui"
|
||
assert snap["labels"]["omnigent.wrapper"] == "claude-code-native-ui"
|
||
assert snap["labels"]["omnigent.ui"] == "terminal"
|
||
assert snap["external_session_id"] == "11111111-2222-3333-4444-555555555555"
|
||
|
||
|
||
async def test_get_session_agent_name_is_spec_name_after_switch(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""After an in-place agent switch the snapshot reports the spec's name.
|
||
|
||
The switch route binds the session to a clone row named
|
||
``"<builtin> (switch ag_…)"`` for agent-store disambiguation, but
|
||
clients (REPL toolbar, web sidebar) display ``agent_name``
|
||
verbatim — the snapshot must surface the spec's clean identity
|
||
(e.g. ``"claude-native-ui"``), not the clone row's name.
|
||
|
||
Drives the REAL switch route end-to-end: source session → seeded
|
||
bindable built-in → ``POST .../switch-agent`` → ``GET`` snapshot.
|
||
"""
|
||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||
|
||
# Source session bound to a session-scoped "nessie" agent.
|
||
source_agent = await create_test_agent(client, name="nessie")
|
||
session_id = source_agent["_session_id"]
|
||
|
||
# Materialize a real claude-native-ui bundle in the artifact store
|
||
# (via a throwaway session-scoped agent), then register a TEMPLATE
|
||
# (built-in, the only kind the switch route binds) sharing it.
|
||
target_agent = await create_test_agent(client, name="claude-native-ui")
|
||
agent_store = SqlAlchemyAgentStore(db_uri)
|
||
target_row = agent_store.get(target_agent["id"])
|
||
assert target_row is not None and target_row.bundle_location is not None
|
||
builtin = agent_store.create(
|
||
"ag_builtin_claude_test",
|
||
"claude-native-ui",
|
||
target_row.bundle_location,
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session_id}/switch-agent",
|
||
json={"agent_id": builtin.id},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
snap = (await client.get(f"/v1/sessions/{session_id}")).json()
|
||
# Preconditions that make this test meaningful: the session is
|
||
# bound to a freshly created CLONE whose row name carries the
|
||
# "(switch …)" disambiguation suffix — i.e. row name ≠ spec name.
|
||
clone_row = agent_store.get(snap["agent_id"])
|
||
assert clone_row is not None
|
||
assert clone_row.name.startswith("claude-native-ui (switch "), (
|
||
f"Expected the switch route to bind a suffixed clone row; got "
|
||
f"{clone_row.name!r}. If unsuffixed, this test no longer covers "
|
||
f"the row-name/spec-name divergence and needs a new setup."
|
||
)
|
||
# The snapshot prefers the spec's clean name over the clone row's.
|
||
# The suffixed name here means clients (REPL toolbar, sidebar)
|
||
# would display "claude-native-ui (switch ag_…)" to the user.
|
||
assert snap["agent_name"] == "claude-native-ui"
|
||
|
||
|
||
async def test_list_sessions_exposes_pending_elicitations_count(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``pending_elicitations_count`` reflects outstanding approval
|
||
prompts so the sidebar can badge sessions whose chat isn't open.
|
||
|
||
Drives the index through its public API
|
||
(``record_publish`` + ``resolve``) — the route handler reads
|
||
the same in-process dict the publish-time side-channel
|
||
populates, so this exercises the full wiring without needing
|
||
a runner / harness in the loop.
|
||
|
||
A regression that drops the field off ``SessionListItem``,
|
||
fails to populate it in ``list_sessions``, or breaks the
|
||
index's lookup would surface as a missing or stale badge —
|
||
invisible to users until they realize sessions are blocked
|
||
and they had no idea.
|
||
"""
|
||
from omnigent.runtime import pending_elicitations
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
|
||
# Sanity: a freshly created session has no outstanding prompts.
|
||
# Without this baseline, a leaked entry from another test could
|
||
# mask the assertions below.
|
||
pending_elicitations.reset_for_tests()
|
||
resp = await client.get("/v1/sessions")
|
||
assert resp.status_code == 200
|
||
row = next((r for r in resp.json()["data"] if r["id"] == session_id), None)
|
||
assert row is not None, f"created session not in list: {resp.json()}"
|
||
# Field present, default 0 — guards against accidentally
|
||
# dropping the field with ``model_dump(exclude_none=True)``
|
||
# because 0 is not None and must serialize through.
|
||
assert row["pending_elicitations_count"] == 0
|
||
|
||
# Simulate the AP-server side-channel registering an outstanding
|
||
# elicitation (the same call ``session_stream.publish`` makes
|
||
# internally for a real ``response.elicitation_request`` event).
|
||
pending_elicitations.record_publish(
|
||
session_id,
|
||
{
|
||
"type": "response.elicitation_request",
|
||
"elicitation_id": "elicit_route_test",
|
||
},
|
||
)
|
||
resp = await client.get("/v1/sessions")
|
||
row = next((r for r in resp.json()["data"] if r["id"] == session_id), None)
|
||
assert row is not None
|
||
# 1 = the route handler invoked ``counts_for`` against the index
|
||
# and populated the field. If still 0, the handler isn't reading
|
||
# the index; if absent, the schema dropped the field on the wire.
|
||
assert row["pending_elicitations_count"] == 1, (
|
||
f"Expected 1 pending after record_publish, got "
|
||
f"{row['pending_elicitations_count']!r}. If 0, the handler "
|
||
f"isn't reading pending_elicitations.counts_for; if KeyError, "
|
||
f"the field is missing from SessionListItem."
|
||
)
|
||
|
||
# Verdict accepted → count clears.
|
||
pending_elicitations.resolve(session_id, "elicit_route_test")
|
||
resp = await client.get("/v1/sessions")
|
||
row = next((r for r in resp.json()["data"] if r["id"] == session_id), None)
|
||
assert row is not None
|
||
# 0 = decrement landed. If still 1, the approval-dispatch path
|
||
# in production won't clear the badge either — the sidebar
|
||
# would show a stale prompt forever.
|
||
assert row["pending_elicitations_count"] == 0
|
||
|
||
pending_elicitations.reset_for_tests()
|
||
|
||
|
||
async def test_get_session_includes_runner_online(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions/{id}`` carries session-scoped runner liveness.
|
||
|
||
Direct attach/resume clients use the single-session snapshot, not the
|
||
sidebar list. If this field is absent there, they fall back to worse
|
||
liveness sources and can misclassify a shared live session as offline.
|
||
|
||
:param client: Test HTTP client backed by the real app.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.get(f"/v1/sessions/{session['id']}")
|
||
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["id"] == session["id"]
|
||
assert body["runner_online"] is True
|
||
|
||
|
||
async def test_get_session_slim_skips_items_and_liveness(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions/{id}?include_items=false&include_liveness=false``
|
||
returns the snapshot without the committed-items read and without
|
||
the liveness lookup.
|
||
|
||
The web chat surface hydrates the transcript via the paginated
|
||
items endpoint and sources liveness from the /health poll, so the
|
||
slim snapshot lets it skip the two most expensive build steps. The
|
||
default (no params) must keep returning both so existing clients
|
||
(REPL resume, direct attach) are unaffected.
|
||
|
||
:param client: Test HTTP client backed by the real app.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"], initial_message="hello")
|
||
session_id = session["id"]
|
||
await _wait_for_idle(client, session_id)
|
||
|
||
slim = await client.get(
|
||
f"/v1/sessions/{session_id}",
|
||
params={"include_items": "false", "include_liveness": "false"},
|
||
)
|
||
assert slim.status_code == 200
|
||
slim_body = slim.json()
|
||
assert slim_body["id"] == session_id
|
||
# Items read skipped — empty list, not an absent field, so clients
|
||
# that parse `items ?? []` see the same shape either way.
|
||
assert slim_body["items"] == []
|
||
# Liveness lookup skipped — both fields unset (serialized as absent
|
||
# or null, never a fabricated False).
|
||
assert slim_body.get("runner_online") is None
|
||
assert slim_body.get("host_online") is None
|
||
|
||
# Default behavior unchanged: the seeded message is in the snapshot
|
||
# and liveness is computed.
|
||
full = await client.get(f"/v1/sessions/{session_id}")
|
||
assert full.status_code == 200
|
||
full_body = full.json()
|
||
# Snapshot items use the nested shape ({"data": {"content": [...]}}),
|
||
# unlike the flat to_api_dict shape of GET /sessions/{id}/items.
|
||
item_texts = [
|
||
block.get("text")
|
||
for item in full_body["items"]
|
||
if item.get("type") == "message"
|
||
for block in item.get("data", {}).get("content", [])
|
||
]
|
||
assert "hello" in item_texts, (
|
||
f"seeded message missing from full snapshot items: {full_body['items']}"
|
||
)
|
||
assert full_body["runner_online"] is True
|
||
|
||
|
||
async def test_get_session_replays_pending_elicitations(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``GET /v1/sessions/{id}`` includes outstanding elicitation event
|
||
payloads in ``pending_elicitations`` so the UI can render the
|
||
ApprovalCard on cold load.
|
||
|
||
Without snapshot replay, the live SSE stream's no-buffer design
|
||
means a prompt emitted before the user opened the chat would
|
||
never render — clicking the sidebar-badged row would land on a
|
||
chat with no card and no way to approve. The fix funnels the
|
||
same event payload through the same parser the live stream uses,
|
||
so the rendered card is byte-identical to what the live path
|
||
would have produced.
|
||
"""
|
||
from omnigent.runtime import pending_elicitations
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
pending_elicitations.reset_for_tests()
|
||
|
||
# Register an outstanding prompt with a real params block so the
|
||
# snapshot returns a payload the UI's parser can consume end-to-end.
|
||
pending_elicitations.record_publish(
|
||
session_id,
|
||
{
|
||
"type": "response.elicitation_request",
|
||
"elicitation_id": "elicit_snapshot_test",
|
||
"method": "elicitation/create",
|
||
"params": {
|
||
"mode": "form",
|
||
"message": "Allow tool **Bash**?",
|
||
"requestedSchema": None,
|
||
"phase": "tool_call",
|
||
"policy_name": "guardrails_default",
|
||
"content_preview": "Bash(ls -la)",
|
||
},
|
||
},
|
||
)
|
||
|
||
resp = await client.get(f"/v1/sessions/{session_id}")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
# Field present even when empty — keeps the wire shape stable.
|
||
# The list serializer uses model_dump (not exclude_none); an
|
||
# empty list must still round-trip.
|
||
assert "pending_elicitations" in body, (
|
||
f"snapshot must include pending_elicitations field; got keys {sorted(body.keys())}. "
|
||
f"If absent, SessionResponse dropped the field or _build_session_response "
|
||
f"isn't reading the index."
|
||
)
|
||
pending_payloads = body["pending_elicitations"]
|
||
# 1 = the outstanding event was carried into the snapshot. If
|
||
# 0, _build_session_response isn't calling snapshot_for; if
|
||
# > 1, the snapshot is bleeding across sessions.
|
||
assert len(pending_payloads) == 1
|
||
payload = pending_payloads[0]
|
||
# Full event shape survives — UI re-parses through the same SSE
|
||
# parser, which reads top-level fields and params.* literals.
|
||
# If `params` is missing/empty, the card renders with no prompt
|
||
# text; if `elicitation_id` is missing, the user couldn't
|
||
# submit a verdict back even if they tried.
|
||
assert payload["elicitation_id"] == "elicit_snapshot_test"
|
||
assert payload["type"] == "response.elicitation_request"
|
||
assert payload["params"]["message"] == "Allow tool **Bash**?"
|
||
assert payload["params"]["content_preview"] == "Bash(ls -la)"
|
||
|
||
pending_elicitations.reset_for_tests()
|
||
|
||
|
||
# ── GET /v1/sessions/{id}/items ──────────────────────────
|
||
|
||
|
||
async def test_list_session_items_returns_items(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Items endpoint returns the user message from session creation."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
initial_message="hello items",
|
||
)
|
||
await _wait_for_idle(client, session["id"])
|
||
|
||
resp = await client.get(f"/v1/sessions/{session['id']}/items")
|
||
assert resp.status_code == 200
|
||
items = resp.json()["data"]
|
||
assert len(items) >= 1
|
||
user_msgs = [i for i in items if i.get("type") == "message" and i.get("role") == "user"]
|
||
assert len(user_msgs) >= 1
|
||
|
||
|
||
async def test_list_session_items_pagination(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Items endpoint supports limit and after cursor."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
initial_message="page test",
|
||
)
|
||
await _wait_for_idle(client, session["id"])
|
||
|
||
resp = await client.get(
|
||
f"/v1/sessions/{session['id']}/items",
|
||
params={"limit": 1},
|
||
)
|
||
assert resp.status_code == 200
|
||
page = resp.json()
|
||
assert len(page["data"]) <= 1
|
||
|
||
|
||
async def test_list_session_items_404_for_nonexistent(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""Items endpoint returns 404 for a session that doesn't exist."""
|
||
resp = await client.get("/v1/sessions/conv_nonexistent/items")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ── GET /v1/sessions/{id} snapshot fields ────────────────
|
||
|
||
|
||
async def test_get_session_includes_title_labels_effort(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""GET snapshot returns title, labels, reasoning_effort, instructions."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
title="snap-test",
|
||
labels={"mode": "debug"},
|
||
)
|
||
await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "medium"},
|
||
)
|
||
|
||
resp = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["title"] == "snap-test"
|
||
assert body["labels"]["mode"] == "debug"
|
||
assert body["reasoning_effort"] == "medium"
|
||
|
||
|
||
async def test_get_session_labels_returns_labels_only(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
GET labels endpoint returns the session id and labels only.
|
||
|
||
:param client: Test HTTP client backed by the real server routes.
|
||
:returns: None.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"mode": "debug", "team": "infra"},
|
||
)
|
||
|
||
resp = await client.get(f"/v1/sessions/{session['id']}/labels")
|
||
|
||
assert resp.status_code == 200
|
||
assert resp.headers["cache-control"] == "no-store"
|
||
assert resp.json() == {
|
||
"id": session["id"],
|
||
"labels": {"mode": "debug", "team": "infra"},
|
||
}
|
||
|
||
|
||
async def test_post_external_assistant_message_persists_and_streams(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
External assistant output appends history without starting a task.
|
||
|
||
This is the path used by ``omnigent claude`` to mirror real
|
||
Claude terminal transcript text into the web UI. It must publish
|
||
a completed output item so connected clients render the text
|
||
immediately without a duplicate synthetic text delta, while
|
||
leaving the session idle.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_assistant_message",
|
||
"data": {
|
||
"agent": "claude-native-ui",
|
||
"text": "hello from real claude",
|
||
"response_id": "resp_claude_external_test",
|
||
},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 202, resp.text
|
||
ack = resp.json()
|
||
assert ack["queued"] is False
|
||
assert isinstance(ack["item_id"], str)
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
body = snap.json()
|
||
assert body["status"] == "idle"
|
||
assistant_items = [
|
||
item
|
||
for item in body["items"]
|
||
if item["type"] == "message" and item["data"]["role"] == "assistant"
|
||
]
|
||
assert len(assistant_items) == 1
|
||
assert assistant_items[0]["data"]["model"] == "claude-native-ui"
|
||
assert assistant_items[0]["data"]["content"] == [
|
||
{"type": "output_text", "text": "hello from real claude"}
|
||
]
|
||
|
||
assert [event["type"] for _, event in published] == ["response.output_item.done"]
|
||
assert all(session_id == session["id"] for session_id, _ in published)
|
||
assert published[0][1]["item"]["id"] == ack["item_id"]
|
||
assert published[0][1]["item"]["model"] == "claude-native-ui"
|
||
|
||
|
||
async def test_post_external_conversation_item_persists_and_streams_visible_items(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
External transcript items mirror terminal Claude into the session.
|
||
|
||
The native Claude forwarder posts user messages, Claude tool calls,
|
||
tool results, and assistant messages through this route. None of
|
||
those posts should start the placeholder session agent.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
events = [
|
||
{
|
||
"item_type": "message",
|
||
"response_id": "resp_terminal_user",
|
||
"source_id": "src_terminal_user_0",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "read TODO"}],
|
||
},
|
||
},
|
||
{
|
||
"item_type": "function_call",
|
||
"response_id": "resp_terminal_assistant",
|
||
"source_id": "src_terminal_assistant_0",
|
||
"item_data": {
|
||
"agent": "claude-native-ui",
|
||
"name": "Read",
|
||
"arguments": '{"file_path":"TODO.md"}',
|
||
"call_id": "toolu_read_1",
|
||
},
|
||
},
|
||
{
|
||
"item_type": "function_call_output",
|
||
"response_id": "resp_terminal_assistant",
|
||
"source_id": "src_terminal_assistant_1",
|
||
"item_data": {"call_id": "toolu_read_1", "output": "todo contents"},
|
||
},
|
||
{
|
||
"item_type": "message",
|
||
"response_id": "resp_terminal_assistant",
|
||
"source_id": "src_terminal_assistant_2",
|
||
"item_data": {
|
||
"role": "assistant",
|
||
"agent": "claude-native-ui",
|
||
"content": [{"type": "output_text", "text": "done"}],
|
||
},
|
||
},
|
||
{
|
||
"item_type": "terminal_command",
|
||
"response_id": "resp_terminal_shell",
|
||
"source_id": "src_terminal_shell_0",
|
||
"item_data": {"kind": "input", "input": "pwd"},
|
||
},
|
||
{
|
||
"item_type": "terminal_command",
|
||
"response_id": "resp_terminal_shell",
|
||
"source_id": "src_terminal_shell_1",
|
||
"item_data": {"kind": "output", "stdout": "/tmp/project", "stderr": ""},
|
||
},
|
||
]
|
||
|
||
for event_data in events:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_conversation_item", "data": event_data},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json()["queued"] is False
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
body = snap.json()
|
||
assert body["status"] == "idle"
|
||
assert [item["type"] for item in body["items"]] == [
|
||
"message",
|
||
"function_call",
|
||
"function_call_output",
|
||
"message",
|
||
"terminal_command",
|
||
"terminal_command",
|
||
]
|
||
assert body["items"][0]["data"]["role"] == "user"
|
||
assert body["items"][1]["data"]["name"] == "Read"
|
||
assert body["items"][2]["data"] == {"call_id": "toolu_read_1", "output": "todo contents"}
|
||
assert body["items"][3]["data"]["role"] == "assistant"
|
||
assert body["items"][4]["data"] == {
|
||
"kind": "input",
|
||
"input": "pwd",
|
||
"stdout": None,
|
||
"stderr": None,
|
||
}
|
||
assert body["items"][5]["data"] == {
|
||
"kind": "output",
|
||
"input": None,
|
||
"stdout": "/tmp/project",
|
||
"stderr": "",
|
||
}
|
||
|
||
assert [event["type"] for _, event in published] == [
|
||
"session.input.consumed",
|
||
"response.output_item.done",
|
||
"response.output_item.done",
|
||
"response.output_item.done",
|
||
"response.output_item.done",
|
||
"response.output_item.done",
|
||
]
|
||
assert published[0][1]["data"]["data"]["content"][0]["text"] == "read TODO"
|
||
assert published[1][1]["item"]["type"] == "function_call"
|
||
assert published[2][1]["item"]["type"] == "function_call_output"
|
||
assert published[3][1]["item"]["type"] == "message"
|
||
assert published[4][1]["item"]["type"] == "terminal_command"
|
||
assert published[5][1]["item"]["type"] == "terminal_command"
|
||
|
||
|
||
async def test_post_external_function_call_output_caps_oversized_output(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A multi-MB native tool result is capped before persist + broadcast.
|
||
|
||
The native (tmux-driven) forwarders read a tool result straight from the
|
||
agent's transcript and POST it as an external function_call_output. Without
|
||
a cap, a multi-MB result would be persisted and broadcast to the web UI as
|
||
one giant SSE frame. This route must bound it — the native analog of the
|
||
harness scaffold's source-side cap.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# 2 MiB — comfortably over the 1 MiB cap so truncation is unambiguous.
|
||
big_output = "x" * (2 * 1024 * 1024)
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "function_call_output",
|
||
"response_id": "resp_native_tool",
|
||
"item_data": {"call_id": "toolu_big_1", "output": big_output},
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
# Persisted copy is capped (carries the notice, far smaller than 2 MiB). A
|
||
# failure here means the native ingest path bypassed the cap.
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
persisted = snap.json()["items"][0]
|
||
assert persisted["type"] == "function_call_output"
|
||
assert "[output truncated by omnigent:" in persisted["data"]["output"]
|
||
assert len(persisted["data"]["output"].encode("utf-8")) <= MAX_TOOL_OUTPUT_BYTES + 200
|
||
assert len(persisted["data"]["output"].encode("utf-8")) < len(big_output)
|
||
|
||
# The broadcast SSE frame is capped too (same item object) — this is the
|
||
# giant-frame-to-the-browser case the cap exists to prevent.
|
||
fco_events = [
|
||
event["item"]["output"]
|
||
for _, event in published
|
||
if event.get("type") == "response.output_item.done"
|
||
and event.get("item", {}).get("type") == "function_call_output"
|
||
]
|
||
assert len(fco_events) == 1, (
|
||
f"expected one broadcast function_call_output; got {len(fco_events)}"
|
||
)
|
||
assert "[output truncated by omnigent:" in fco_events[0]
|
||
assert len(fco_events[0].encode("utf-8")) < len(big_output)
|
||
|
||
|
||
async def test_external_transcript_items_recoverable_via_snapshot_by_item_id(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Reconnect dedupe contract: the item ids the live stream emits
|
||
equal the item ids the snapshot persists, so a client reconciling
|
||
snapshot + live stream by item id sees each item exactly once."""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
agent = await create_test_agent(client, name="claude-native-ui")
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
|
||
# A representative forwarder turn: user prompt, tool call + output,
|
||
# assistant reply.
|
||
transcript = [
|
||
{
|
||
"item_type": "message",
|
||
"response_id": "resp_user",
|
||
"source_id": "src_user_0",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "read TODO"}],
|
||
},
|
||
},
|
||
{
|
||
"item_type": "function_call",
|
||
"response_id": "resp_asst",
|
||
"source_id": "src_asst_0",
|
||
"item_data": {
|
||
"agent": "claude-native-ui",
|
||
"name": "Read",
|
||
"arguments": '{"file_path":"TODO.md"}',
|
||
"call_id": "toolu_read_1",
|
||
},
|
||
},
|
||
{
|
||
"item_type": "function_call_output",
|
||
"response_id": "resp_asst",
|
||
"source_id": "src_asst_1",
|
||
"item_data": {"call_id": "toolu_read_1", "output": "todo contents"},
|
||
},
|
||
{
|
||
"item_type": "message",
|
||
"response_id": "resp_asst",
|
||
"source_id": "src_asst_2",
|
||
"item_data": {
|
||
"role": "assistant",
|
||
"agent": "claude-native-ui",
|
||
"content": [{"type": "output_text", "text": "done"}],
|
||
},
|
||
},
|
||
]
|
||
for event_data in transcript:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session_id}/events",
|
||
json={"type": "external_conversation_item", "data": event_data},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
# Item ids the live stream carried: session.input.consumed
|
||
# (data.item_id) for inputs, response.output_item.done (item.id)
|
||
# for outputs.
|
||
streamed_ids: set[str] = set()
|
||
for sid, ev in published:
|
||
assert sid == session_id
|
||
if ev["type"] == "session.input.consumed":
|
||
streamed_ids.add(ev["data"]["item_id"])
|
||
elif ev["type"] == "response.output_item.done":
|
||
streamed_ids.add(ev["item"]["id"])
|
||
assert len(streamed_ids) == 4, f"expected 4 streamed ids, got {streamed_ids!r}"
|
||
|
||
snap = (await client.get(f"/v1/sessions/{session_id}")).json()
|
||
snapshot_ids = [item["id"] for item in snap["items"]]
|
||
assert len(snapshot_ids) == len(set(snapshot_ids)), (
|
||
f"snapshot has duplicate item ids: {snapshot_ids!r}"
|
||
)
|
||
# Equal sets ⇒ deduping snapshot ∪ live-stream by id loses nothing
|
||
# and duplicates nothing.
|
||
assert streamed_ids == set(snapshot_ids), (
|
||
f"streamed vs snapshot ids diverge — "
|
||
f"streamed-only={streamed_ids - set(snapshot_ids)!r} "
|
||
f"snapshot-only={set(snapshot_ids) - streamed_ids!r}"
|
||
)
|
||
|
||
|
||
async def test_post_external_session_status_publishes_session_status(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_status`` posts a typed SessionStatusEvent.
|
||
|
||
The native Claude forwarder posts this when Claude Code's Stop /
|
||
StopFailure hooks fire so the web UI's idle/running indicator
|
||
updates without going through the Omnigent task lifecycle.
|
||
A regression here would break the idle indicator for
|
||
``omnigent claude`` sessions: Omnigent would never learn Claude
|
||
finished and the UI would stay stuck on whatever transient
|
||
state it last saw.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_status", "data": {"status": "idle"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert [event["type"] for _, event in published] == ["session.status"]
|
||
assert published[0][0] == session["id"]
|
||
assert published[0][1]["status"] == "idle"
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert "response_id" not in published[0][1]
|
||
|
||
|
||
async def test_post_external_session_status_failed_surfaces_output_and_reauth(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A ``failed`` edge with ``output`` surfaces a typed error on the stream (#1108).
|
||
|
||
A native forwarder (e.g. codex-native on an expired login) posts the
|
||
terminal failure reason as ``data.output`` and flags ``reauth_required``.
|
||
The handler must surface it as the ``session.status`` edge's ``error`` so a
|
||
*top-level* session sees the reason — not only the sub-agent parent path.
|
||
``reauth_required`` selects the ``codex_reauth_required`` code.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda session_id, event: published.append((session_id, event)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_status",
|
||
"data": {
|
||
"status": "failed",
|
||
"output": "401 Unauthorized\n\nRun `codex login` and retry.",
|
||
"reauth_required": True,
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
assert published[0][1]["status"] == "failed"
|
||
error = published[0][1]["error"]
|
||
assert error is not None
|
||
assert error["code"] == "codex_reauth_required"
|
||
assert "401 Unauthorized" in error["message"]
|
||
|
||
|
||
async def test_post_external_session_status_carries_response_id(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_status`` can bind a status edge to a response.
|
||
|
||
Codex-native terminal turns do not emit ``response.created`` /
|
||
``response.completed`` through AP. The optional ``response_id`` lets
|
||
web attach running / interrupted / idle lifecycle to the assistant
|
||
bubble that will later be persisted with the same response id.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_status",
|
||
"data": {"status": "running", "response_id": "codex_turn_123"},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
assert len(published) == 1
|
||
published_session_id, event = published[0]
|
||
assert published_session_id == session["id"]
|
||
assert event["type"] == "session.status"
|
||
assert event["conversation_id"] == session["id"]
|
||
assert event["status"] == "running"
|
||
assert event["response_id"] == "codex_turn_123"
|
||
|
||
|
||
async def test_publish_status_keeps_failed_sticky_against_trailing_idle(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A ``failed`` session status is not downgraded by a trailing ``idle``.
|
||
|
||
A claude-native turn error arrives via the ``StopFailure`` hook
|
||
(→ ``failed``), but the now-quiet pane then makes the runner's
|
||
PTY-activity watcher emit a trailing ``idle``. That ``idle`` must not
|
||
erase the error state before the user can see it; only the next
|
||
``running`` edge (new activity) clears ``failed``. This pins the
|
||
sticky-``failed`` guard in ``_publish_status`` — without it, the red
|
||
error badge would flash for ~1s then revert to idle on every failed
|
||
claude-native turn.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
published: list[dict[str, Any]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda _session_id, event: published.append(event),
|
||
)
|
||
sid = "conv_sticky_failed"
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
try:
|
||
sessions_module._publish_status(sid, "failed")
|
||
# Trailing PTY ``idle`` must be suppressed while ``failed`` stands.
|
||
sessions_module._publish_status(sid, "idle")
|
||
# New activity clears ``failed``...
|
||
sessions_module._publish_status(sid, "running")
|
||
# ...after which ``idle`` publishes normally again.
|
||
sessions_module._publish_status(sid, "idle")
|
||
cache_after = sessions_module._session_status_cache.get(sid)
|
||
finally:
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
|
||
# The idle posted right after ``failed`` is absent — the error
|
||
# survived until ``running`` cleared it. A published sequence
|
||
# containing ``[failed, idle, ...]`` would mean the sticky guard
|
||
# regressed and the trailing PTY idle clobbered the error.
|
||
assert [event["status"] for event in published] == ["failed", "running", "idle"]
|
||
# The final idle (after running) is honored, so the cache is current.
|
||
assert cache_after == "idle"
|
||
|
||
|
||
async def test_publish_status_tracks_in_flight_response_id(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``_publish_status`` records the in-flight response id and clears it on end.
|
||
|
||
A ``running``/``waiting`` edge carrying a ``response_id`` opens the
|
||
``_session_active_response_cache`` entry (projected onto the snapshot as
|
||
``active_response_id`` so a mid-turn reconnect reopens the streaming
|
||
``activeResponse``); any ``idle``/``failed`` edge clears it. A bare
|
||
``running`` (no id, e.g. the PTY badge edge) must NOT clobber a tracked id.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda _session_id, _event: None,
|
||
)
|
||
sid = "conv_active_resp"
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
sessions_module._session_active_response_cache.pop(sid, None)
|
||
try:
|
||
# Turn start: running with the turn id opens the tracked entry.
|
||
sessions_module._publish_status(sid, "running", response_id="resp_turn_1")
|
||
assert sessions_module._session_active_response_cache.get(sid) == "resp_turn_1"
|
||
# Bare PTY running (no id) must not erase the tracked turn id.
|
||
sessions_module._publish_status(sid, "running")
|
||
assert sessions_module._session_active_response_cache.get(sid) == "resp_turn_1"
|
||
# Turn end: idle clears it.
|
||
sessions_module._publish_status(sid, "idle", response_id="resp_turn_1")
|
||
assert sessions_module._session_active_response_cache.get(sid) is None
|
||
finally:
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
sessions_module._session_active_response_cache.pop(sid, None)
|
||
|
||
|
||
async def test_patch_runner_rebind_clears_stale_failed_status(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
CLI resume rebind clears a stale failed status after runner init.
|
||
|
||
``omnigent codex --resume`` binds a newly launched runner through
|
||
``PATCH /v1/sessions/{id}``. That path has its own runner-init POST,
|
||
separate from ``_resume_session_on_host``. If it succeeds but does not
|
||
publish the recovery transition, a stale ``failed`` cache entry keeps
|
||
the session snapshot stuck on an old native-terminal startup error.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
class _RecoveringRunnerClient:
|
||
"""Runner client that records PATCH-path session init."""
|
||
|
||
def __init__(self) -> None:
|
||
""":returns: None."""
|
||
self.posts: list[dict[str, Any]] = []
|
||
|
||
async def post(
|
||
self,
|
||
url: str,
|
||
*,
|
||
json: dict[str, Any],
|
||
timeout: float,
|
||
) -> httpx.Response:
|
||
"""
|
||
Record the runner init POST.
|
||
|
||
:param url: Runner URL path, e.g. ``"/v1/sessions"``.
|
||
:param json: Request body sent to the runner.
|
||
:param timeout: HTTP timeout in seconds, e.g. ``10.0``.
|
||
:returns: HTTP 200 response.
|
||
"""
|
||
self.posts.append({"url": url, "json": json, "timeout": timeout})
|
||
return httpx.Response(200, request=httpx.Request("POST", url))
|
||
|
||
def _registered_runner_id(
|
||
_runner_router: Any,
|
||
raw_runner_id: str,
|
||
*,
|
||
user_id: str | None = None,
|
||
) -> str:
|
||
"""
|
||
Accept the test runner id without a real tunnel registry.
|
||
|
||
:param _runner_router: Ignored runner router placeholder.
|
||
:param raw_runner_id: Requested runner id, e.g. ``"runner_recovered"``.
|
||
:param user_id: Optional authenticated user id, e.g. ``"alice@example.com"``.
|
||
:returns: The trimmed runner id.
|
||
"""
|
||
return raw_runner_id.strip()
|
||
|
||
async def _get_runner_client(
|
||
_session_id: str,
|
||
_runner_router: Any,
|
||
) -> _RecoveringRunnerClient:
|
||
"""
|
||
Return the recovering runner client for the patched session.
|
||
|
||
:param _session_id: Session id being rebound, e.g. ``"conv_abc123"``.
|
||
:param _runner_router: Ignored runner router placeholder.
|
||
:returns: Runner client stub.
|
||
"""
|
||
return runner_client
|
||
|
||
async def _ensure_runner_relay_ready(
|
||
_session_id: str,
|
||
_runner_id: str,
|
||
_runner_client: _RecoveringRunnerClient,
|
||
_conversation_store: Any,
|
||
) -> None:
|
||
"""
|
||
Skip relay startup; this test targets the PATCH init branch.
|
||
|
||
:param _session_id: Session id, e.g. ``"conv_abc123"``.
|
||
:param _runner_id: Runner id, e.g. ``"runner_recovered"``.
|
||
:param _runner_client: Runner client stub.
|
||
:param _conversation_store: Conversation store from the route.
|
||
:returns: None.
|
||
"""
|
||
|
||
published: list[dict[str, Any]] = []
|
||
runner_client = _RecoveringRunnerClient()
|
||
monkeypatch.setattr(sessions_module, "_registered_runner_id", _registered_runner_id)
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _get_runner_client)
|
||
monkeypatch.setattr(sessions_module, "_ensure_runner_relay_ready", _ensure_runner_relay_ready)
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda _session_id, event: published.append(event),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
sid = session["id"]
|
||
# The monkeypatched runner client can see setup work during session
|
||
# creation. This test targets the later PATCH rebind path only.
|
||
runner_client.posts.clear()
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
try:
|
||
sessions_module._publish_status(
|
||
sid,
|
||
"failed",
|
||
sessions_module.ErrorDetail(
|
||
code="native_terminal_start_failed",
|
||
message="old startup failure",
|
||
),
|
||
)
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{sid}",
|
||
json={"runner_id": "runner_recovered"},
|
||
)
|
||
cache_after = sessions_module._session_status_cache.get(sid)
|
||
finally:
|
||
sessions_module._session_status_cache.pop(sid, None)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
assert runner_client.posts == [
|
||
{
|
||
"url": "/v1/sessions",
|
||
"json": {
|
||
"session_id": sid,
|
||
"agent_id": agent["id"],
|
||
"sub_agent_name": None,
|
||
},
|
||
"timeout": 10.0,
|
||
}
|
||
]
|
||
assert [event["status"] for event in published] == ["failed", "idle"]
|
||
assert cache_after == "idle"
|
||
|
||
|
||
async def test_post_external_session_status_idle_forwards_persisted_assistant_output(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Native idle status forwarding includes AP-persisted assistant text.
|
||
|
||
The native forwarder posts transcript items to Omnigent server, then posts
|
||
``external_session_status: idle``. The runner's sub-agent registry
|
||
needs the durable assistant text in that status forward because it
|
||
may not have the Omnigent transcript in local memory.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
forwarded: list[dict[str, Any]] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""
|
||
Capture forwarded runner events.
|
||
|
||
:param request: Request sent to the fake runner.
|
||
:returns: Accepted response.
|
||
"""
|
||
forwarded.append(
|
||
{
|
||
"path": request.url.path,
|
||
"body": json.loads(request.content),
|
||
}
|
||
)
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient:
|
||
"""
|
||
Resolve the session to the fake runner client.
|
||
|
||
:param session_id: Session id being routed.
|
||
:param runner_router: Real app runner router, unused here.
|
||
:returns: The fake runner client.
|
||
"""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(client, agent["id"])
|
||
child_resp = await client.post(
|
||
"/v1/sessions",
|
||
json={
|
||
"agent_id": agent["id"],
|
||
"parent_session_id": parent["id"],
|
||
"sub_agent_name": "worker",
|
||
"title": "worker:native",
|
||
},
|
||
)
|
||
assert child_resp.status_code == 201, child_resp.text
|
||
child = child_resp.json()
|
||
|
||
item_resp = await client.post(
|
||
f"/v1/sessions/{child['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
"response_id": "resp_native_done",
|
||
"source_id": "src_native_done",
|
||
"item_data": {
|
||
"role": "assistant",
|
||
"agent": "claude-native-ui",
|
||
"content": [
|
||
{"type": "output_text", "text": "AP_NATIVE_DONE"},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
)
|
||
assert item_resp.status_code == 202, item_resp.text
|
||
|
||
forwarded.clear()
|
||
status_resp = await client.post(
|
||
f"/v1/sessions/{child['id']}/events",
|
||
json={"type": "external_session_status", "data": {"status": "idle"}},
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
|
||
assert status_resp.status_code == 202, status_resp.text
|
||
assert forwarded == [
|
||
{
|
||
"path": f"/v1/sessions/{child['id']}/events",
|
||
"body": {
|
||
"type": "external_session_status",
|
||
"data": {"status": "idle", "output": "AP_NATIVE_DONE"},
|
||
"model_override": None,
|
||
"tools": None,
|
||
},
|
||
}
|
||
]
|
||
|
||
|
||
async def test_post_external_session_status_propagates_runner_delivery_failure(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Runner delivery failure for a non-Codex sub-agent is preserved by AP.
|
||
|
||
Native child terminal status is only successful when the runner confirms
|
||
delivery to the parent's inbox. If Omnigent returned ``{"queued": false}`` after
|
||
a runner 503, the child forwarder would believe the result was ACKed while
|
||
the parent never receives it.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""
|
||
Reject the forwarded terminal status like a runner missing parent state.
|
||
|
||
:param request: Request sent to the fake runner.
|
||
:returns: Session-create acceptance or 503 delivery-not-confirmed
|
||
response.
|
||
"""
|
||
if request.method == "POST" and request.url.path == "/v1/sessions":
|
||
body = json.loads(request.content)
|
||
return httpx.Response(201, json={"id": body["session_id"]})
|
||
assert request.url.path.endswith("/events")
|
||
return httpx.Response(
|
||
503,
|
||
json={
|
||
"error": "subagent_delivery_not_confirmed",
|
||
"reason": "missing_parent_inbox",
|
||
},
|
||
)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient:
|
||
"""
|
||
Resolve every session to the fake runner client.
|
||
|
||
:param session_id: Session id being routed.
|
||
:param runner_router: Real app runner router, unused here.
|
||
:returns: The fake runner client.
|
||
"""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(client, agent["id"])
|
||
child_resp = await client.post(
|
||
"/v1/sessions",
|
||
json={
|
||
"agent_id": agent["id"],
|
||
"parent_session_id": parent["id"],
|
||
"sub_agent_name": "worker",
|
||
"title": "worker:native",
|
||
},
|
||
)
|
||
assert child_resp.status_code == 201, child_resp.text
|
||
child = child_resp.json()
|
||
|
||
status_resp = await client.post(
|
||
f"/v1/sessions/{child['id']}/events",
|
||
json={"type": "external_session_status", "data": {"status": "idle"}},
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
|
||
assert status_resp.status_code == 503, status_resp.text
|
||
error = status_resp.json()["error"]
|
||
assert error["code"] == "runner_unavailable"
|
||
# The runner's delivery-not-confirmed reason is retained in AP's error
|
||
# message. If absent, Omnigent flattened the failure and made diagnosis harder.
|
||
assert "missing_parent_inbox" in error["message"]
|
||
|
||
|
||
async def test_post_external_output_text_delta_publishes_transient_delta(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_output_text_delta`` emits a live text delta only.
|
||
|
||
Codex-native uses this for app-server ``item/agentMessage/delta``
|
||
notifications. The event must be visible on the session SSE stream
|
||
but absent from persisted history; the completed assistant item is
|
||
mirrored separately by ``external_conversation_item``.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_output_text_delta", "data": {"delta": "hel"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert published == [
|
||
(
|
||
session["id"],
|
||
{"type": "response.output_text.delta", "delta": "hel"},
|
||
)
|
||
]
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200, snap.text
|
||
assert snap.json()["items"] == []
|
||
|
||
|
||
async def test_post_external_output_text_delta_rejects_malformed_delta(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_output_text_delta`` fails loud on non-string deltas.
|
||
|
||
Without this validation, a malformed terminal-forwarder payload
|
||
would publish a non-conforming ``response.output_text.delta`` event
|
||
and strict SDK clients would drop it downstream.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture any accidental stream publish before validation fails.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_output_text_delta", "data": {"delta": {"text": "bad"}}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_output_text_delta requires string data.delta" in resp.text
|
||
assert published == []
|
||
|
||
|
||
async def test_post_external_output_reasoning_delta_started_publishes_started_then_delta(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_output_reasoning_delta`` with ``started`` emits started + delta.
|
||
|
||
The antigravity-native reader uses this for a Gemini Thinking-model
|
||
``plannerResponse.thinking`` stream. The first delta of a block sets
|
||
``started`` so the route precedes the ``response.reasoning_text.delta`` with
|
||
one ``response.reasoning.started`` (the SPA new-block marker). Both events
|
||
must be visible on the SSE stream and nothing persisted to history (reasoning
|
||
has no completed item).
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_output_reasoning_delta",
|
||
"data": {"delta": "Let me think", "started": True},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert published == [
|
||
(session["id"], {"type": "response.reasoning.started"}),
|
||
(
|
||
session["id"],
|
||
{"type": "response.reasoning_text.delta", "delta": "Let me think"},
|
||
),
|
||
]
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200, snap.text
|
||
assert snap.json()["items"] == []
|
||
|
||
|
||
async def test_post_external_output_reasoning_delta_continuation_publishes_delta_only(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A continuation reasoning delta (``started`` false/omitted) emits delta only.
|
||
|
||
Only the first delta of a reasoning block opens it with
|
||
``response.reasoning.started``; later deltas publish a bare
|
||
``response.reasoning_text.delta``.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_output_reasoning_delta",
|
||
"data": {"delta": " more thought", "started": False},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
assert published == [
|
||
(
|
||
session["id"],
|
||
{"type": "response.reasoning_text.delta", "delta": " more thought"},
|
||
)
|
||
]
|
||
|
||
|
||
async def test_post_external_output_reasoning_delta_rejects_malformed_delta(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_output_reasoning_delta`` fails loud on a non-string delta.
|
||
|
||
Mirrors the text-delta validation: a malformed payload must not publish a
|
||
non-conforming ``response.reasoning_text.delta`` that strict SDK clients drop.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture any accidental stream publish before validation fails.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_output_reasoning_delta", "data": {"delta": 123}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_output_reasoning_delta requires string data.delta" in resp.text
|
||
assert published == []
|
||
|
||
|
||
async def test_post_external_session_interrupted_publishes_session_interrupted(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_interrupted`` emits a live interruption signal only.
|
||
|
||
Codex-native uses this when app-server reports a terminal
|
||
``turn/completed`` status of ``interrupted``. The event must decorate
|
||
the active web turn as cancelled, but must not persist a transcript item
|
||
or start / steer an Omnigent task.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_interrupted",
|
||
"data": {"response_id": "codex_turn_123"},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert len(published) == 1
|
||
published_session_id, event = published[0]
|
||
assert published_session_id == session["id"]
|
||
assert event["type"] == "session.interrupted"
|
||
assert isinstance(event["data"]["requested_at"], int)
|
||
assert event["data"]["response_id"] == "codex_turn_123"
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200, snap.text
|
||
assert snap.json()["items"] == []
|
||
|
||
|
||
async def test_post_interrupt_without_data_field_is_accepted(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A bare ``{"type": "interrupt"}`` (no ``data`` key) is valid input.
|
||
|
||
Control events carry no payload, and deployed runner-side clients
|
||
(``sys_cancel_task``'s stop/interrupt forward) post them without
|
||
``data``. When ``SessionEventInput.data`` was required, those
|
||
clients got ``422 missing body.data`` and sub-agent cancellation
|
||
was broken in production — this pins the default.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "interrupt"},
|
||
)
|
||
# 202 (not 422) proves ``data`` defaults to ``{}`` at the schema
|
||
# layer; a 422 here means the pydantic default regressed and every
|
||
# deployed bare-control client breaks again.
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
# The interrupt actually took effect (not just validated): the
|
||
# route published the cancellation signal for the web turn.
|
||
interrupted = [e for _sid, e in published if e.get("type") == "session.interrupted"]
|
||
assert len(interrupted) == 1, (
|
||
f"expected one session.interrupted publish for a bare interrupt "
|
||
f"event, got types {[e.get('type') for _sid, e in published]!r}"
|
||
)
|
||
|
||
|
||
async def test_post_external_output_text_delta_carries_streaming_identifiers(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``message_id`` / ``index`` / ``final`` pass through to the SSE event.
|
||
|
||
claude-native live streaming needs these so the web UI can scope an
|
||
in-flight buffer to one assistant message, order its chunks, and know
|
||
when the stream ends. Fails if any is dropped (UI can't reconcile the
|
||
preview with the final item) — including the falsy ``index: 0`` /
|
||
``final: False`` values, which ``exclude_none`` must keep.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_output_text_delta",
|
||
"data": {"delta": "Hel", "message_id": "msg-uuid", "index": 0, "final": False},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
# All four fields survive — including index 0 and final False, which a
|
||
# naive `if value:` filter would have dropped.
|
||
assert published == [
|
||
(
|
||
session["id"],
|
||
{
|
||
"type": "response.output_text.delta",
|
||
"delta": "Hel",
|
||
"message_id": "msg-uuid",
|
||
"index": 0,
|
||
"final": False,
|
||
},
|
||
)
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"bad_data,expected_msg",
|
||
[
|
||
(
|
||
{"delta": "x", "message_id": 7},
|
||
"external_output_text_delta data.message_id must be a string",
|
||
),
|
||
(
|
||
{"delta": "x", "index": "0"},
|
||
"external_output_text_delta data.index must be an integer",
|
||
),
|
||
(
|
||
{"delta": "x", "index": True},
|
||
"external_output_text_delta data.index must be an integer",
|
||
),
|
||
(
|
||
{"delta": "x", "final": "yes"},
|
||
"external_output_text_delta data.final must be a boolean",
|
||
),
|
||
],
|
||
ids=["non-string-message_id", "string-index", "bool-index", "non-bool-final"],
|
||
)
|
||
async def test_post_external_output_text_delta_rejects_malformed_identifiers(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
bad_data: dict[str, Any],
|
||
expected_msg: str,
|
||
) -> None:
|
||
"""
|
||
Wrong-typed streaming identifiers fail loud and publish nothing.
|
||
|
||
A malformed terminal-forwarder payload must not produce a
|
||
non-conforming ``response.output_text.delta`` (which strict SDK
|
||
clients would drop). ``index: True`` is called out because ``bool``
|
||
is an ``int`` subclass and would otherwise slip through.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture any accidental stream publish before validation fails.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_output_text_delta", "data": bad_data},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert expected_msg in resp.text
|
||
assert published == []
|
||
|
||
|
||
async def test_post_external_session_status_rejects_unknown_status(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Unknown status values are rejected with a 400.
|
||
|
||
Without this guard a typo in the forwarder (``"Idle"`` vs
|
||
``"idle"``) would propagate to the wire as a non-conforming
|
||
``session.status`` payload and the SDK's strict event adapter
|
||
would silently drop the event downstream — exactly the kind of
|
||
invisible failure rule 15 (fail loud) exists to prevent.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_status", "data": {"status": "Idle"}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_session_status" in resp.text
|
||
|
||
|
||
async def test_post_external_session_usage_publishes_session_usage(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_usage`` posts a typed SessionUsageEvent and
|
||
persists the value on the conversation labels.
|
||
|
||
The claude-native forwarder posts this whenever Claude's transcript
|
||
grows a fresh ``message.usage`` block so the web context ring
|
||
updates without waiting for a ``response.completed`` event (Claude
|
||
Code runs in a separate process and never produces one). Both the
|
||
live SSE path and the snapshot-restore path read from this event:
|
||
live via the broadcast, restore via the conversation label.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 44568, "input_tokens": 6, "output_tokens": 554},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert [event["type"] for _, event in published] == ["session.usage"]
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert published[0][1]["context_tokens"] == 44568
|
||
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["last_total_tokens"] == 44568
|
||
|
||
|
||
async def test_external_session_usage_broadcasts_parent_subtree_cost_not_own(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A parent's ``session.usage`` broadcast carries its SUBTREE cost, not own.
|
||
|
||
A sub-agent persists its spend on its own child conversation. If the
|
||
parent's own-cost flush broadcast only the parent's own cost, the live
|
||
badge would drop back to own-cost on every parent flush and hide in-flight
|
||
sub-agent spend until the next child flush (the badge would oscillate
|
||
own ⇄ subtree). The broadcast must match the GET snapshot's subtree total.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(client, agent["id"], title="parent")
|
||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||
child = conv_store.create_conversation(
|
||
kind="sub_agent",
|
||
title="worker",
|
||
parent_conversation_id=parent["id"],
|
||
agent_id=agent["id"],
|
||
)
|
||
# Sub-agent has $2.50 of priced spend, persisted on the CHILD conversation.
|
||
conv_store.set_session_usage(child.id, {"total_cost_usd": 2.5})
|
||
|
||
# The parent flushes its OWN $1.00 cumulative cost.
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"cumulative_cost_usd": 1.0}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
parent_usage = [ev for sid, ev in published if ev.get("conversation_id") == parent["id"]]
|
||
assert parent_usage, "expected a session.usage broadcast for the parent"
|
||
# 3.5 = parent own $1.00 + sub-agent $2.50. A value of 1.0 means the parent's
|
||
# live badge regressed to own-cost (the bug) — it would show $1.00 right
|
||
# after the parent flushes and only jump back to $3.50 on the next child
|
||
# flush, instead of staying at the subtree total like the snapshot.
|
||
assert parent_usage[-1]["total_cost_usd"] == 3.5
|
||
|
||
|
||
async def test_post_external_session_usage_dynamic_context_window_overrides_snapshot(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A posted ``context_window`` overrides the spec's static value on snapshot.
|
||
|
||
The claude-native forwarder resolves the *actual* context window
|
||
from the user's selected Claude model + ``[1m]`` alias and pushes
|
||
it through this event. The session snapshot must prefer that
|
||
observed value over the spec's conservative static default so the
|
||
ring reflects the user's real tier (e.g. 1M for opus[1m] users).
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 250_000, "context_window": 1_000_000},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
assert [event["type"] for _, event in published] == ["session.usage"]
|
||
assert published[0][1]["context_tokens"] == 250_000
|
||
assert published[0][1]["context_window"] == 1_000_000
|
||
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["last_total_tokens"] == 250_000
|
||
assert snapshot["context_window"] == 1_000_000
|
||
|
||
|
||
async def test_post_external_session_usage_window_only_payload_persists_window(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A window-only post updates the window without zeroing tokens.
|
||
|
||
The forwarder may post just ``context_window`` when the user
|
||
switches models without producing a fresh ``message.usage`` block
|
||
(e.g. via Claude Code's ``/model`` slash command between turns).
|
||
The server must not require both fields.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"context_tokens": 100}},
|
||
)
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"context_window": 1_000_000}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
last = published[-1][1]
|
||
assert last["context_window"] == 1_000_000
|
||
assert "context_tokens" not in last
|
||
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["last_total_tokens"] == 100
|
||
assert snapshot["context_window"] == 1_000_000
|
||
|
||
|
||
async def test_post_external_session_usage_rejects_empty_payload(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A payload missing both context_tokens and context_window 400s.
|
||
|
||
Defends against a forwarder logic bug or partial deploy that would
|
||
otherwise round-trip a no-op event to Omnigent every poll.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_session_usage" in resp.text
|
||
|
||
|
||
def _read_session_usage(db_uri: str, session_id: str) -> dict[str, Any]:
|
||
"""
|
||
Read a conversation's persisted ``session_usage`` directly from the DB.
|
||
|
||
The session snapshot API does not expose cost, so cumulative-usage tests
|
||
read the ``session_usage`` column via a reader store on the same DB file
|
||
the app writes to.
|
||
|
||
:param db_uri: The per-test SQLite URI (same one the ``client`` app uses).
|
||
:param session_id: Conversation id to read.
|
||
:returns: The parsed ``session_usage`` dict (empty when unset).
|
||
"""
|
||
conv = SqlAlchemyConversationStore(db_uri).get_conversation(session_id)
|
||
return dict(conv.session_usage) if conv and conv.session_usage else {}
|
||
|
||
|
||
async def test_external_session_usage_persists_cumulative_cost(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
A claude-native ``cumulative_cost_usd`` is persisted to ``session_usage``.
|
||
|
||
claude-native can't produce a ``response.completed`` (Claude Code is a
|
||
separate process), so the Omnigent relay's ``_accumulate_session_usage`` never
|
||
runs for it. Instead the forwarder sends Claude Code's own cumulative
|
||
``cost.total_cost_usd`` on this event; the server must persist it so a
|
||
Cost-Ask policy reading ``event.context.usage.total_cost_usd`` sees a real
|
||
value. A failure here means native cost stays 0 and the policy never fires.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 44568, "cumulative_cost_usd": 0.42},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# Claude Code's own cumulative cost is stored verbatim.
|
||
assert usage.get("total_cost_usd") == 0.42
|
||
|
||
|
||
async def test_external_session_usage_persists_display_and_policy_costs(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
claude-native's display (S) and policy (max(S,C)) costs persist separately.
|
||
|
||
The forwarder posts ``cumulative_cost_usd`` = the statusLine total S
|
||
(display, matches /cost) and ``policy_cost_usd`` = the real-time gate
|
||
figure. They must land in distinct ``session_usage`` keys: storing the
|
||
enforcement value in ``total_cost_usd`` would re-inflate the badge and
|
||
the daily rollup, which is exactly what this split avoids.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 100, "cumulative_cost_usd": 0.10, "policy_cost_usd": 0.30},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# Display cost = S verbatim (drives badge + daily rollup).
|
||
assert usage.get("total_cost_usd") == 0.10
|
||
# Enforcement cost stored separately (seeds the cost-budget gate).
|
||
assert usage.get("policy_cost_usd") == 0.30
|
||
|
||
|
||
async def test_external_session_usage_policy_cost_only_post_accepted(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
A post carrying only ``policy_cost_usd`` is accepted; display S unchanged.
|
||
|
||
Mid-sub-agent-run the displayed statusLine total (``cumulative_cost_usd``)
|
||
is frozen, so the forwarder posts only the advancing enforcement cost. The
|
||
route must accept that payload (``policy_cost_usd`` counts as a cumulative
|
||
usage field) and update ``policy_cost_usd`` while leaving ``total_cost_usd``
|
||
at the last S — otherwise the gate would stop seeing in-flight spend, or
|
||
the badge would jump.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# Seed both at the turn start.
|
||
seed = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 0.10, "policy_cost_usd": 0.10},
|
||
},
|
||
)
|
||
assert seed.status_code == 202, seed.text
|
||
|
||
# Mid-turn: only the enforcement cost advances (no cumulative_cost_usd).
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"policy_cost_usd": 0.50}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# Display stays at the last S (frozen mid-turn).
|
||
assert usage.get("total_cost_usd") == 0.10
|
||
# Enforcement advanced so the gate sees in-flight sub-agent spend.
|
||
assert usage.get("policy_cost_usd") == 0.50
|
||
|
||
|
||
async def test_external_session_usage_cumulative_cost_is_set_not_added(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
Successive cumulative-cost posts SET (not accumulate) — native reports
|
||
running totals, so two posts of 0.42 then 0.90 must leave 0.90, not 1.32.
|
||
|
||
A failure (1.32) would mean the native path wrongly reused the Omnigent relay's
|
||
add-delta semantics, double-counting the session cost.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
for cost in (0.42, 0.90):
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 100, "cumulative_cost_usd": cost},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage.get("total_cost_usd") == 0.90
|
||
|
||
|
||
async def test_external_session_usage_cost_is_monotonic(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""
|
||
A cumulative-usage post may only RAISE the persisted costs, never lower them.
|
||
|
||
The ``external_session_usage`` event carries the session owner's own bearer
|
||
token (the forwarder uses no privileged identity), so an owner could replay
|
||
it with a falsified low cost. Both the display cost (``total_cost_usd``) and
|
||
the enforcement cost (``policy_cost_usd``, which the cost-budget gate reads)
|
||
are clamped monotonic so such a post is a no-op — it can't reset the gate to
|
||
~0 and re-enable spending past the budget. A regression (the low value
|
||
landing) would re-open the budget-bypass.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
high = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 0.90, "policy_cost_usd": 0.95},
|
||
},
|
||
)
|
||
assert high.status_code == 202, high.text
|
||
|
||
# Falsified low report — must be ignored, not stored.
|
||
low = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 0.0, "policy_cost_usd": 0.0},
|
||
},
|
||
)
|
||
assert low.status_code == 202, low.text
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage.get("total_cost_usd") == 0.90
|
||
assert usage.get("policy_cost_usd") == 0.95
|
||
|
||
|
||
async def test_external_session_usage_codex_tokens_priced(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
codex-native cumulative tokens are SET and priced into ``total_cost_usd``.
|
||
|
||
codex reports cumulative token totals but no cost, so the server prices
|
||
them via ``fetch_model_pricing(model)``. Catalog lookup is disabled in
|
||
tests, so we stub pricing. A failure means codex sessions never get a cost
|
||
even though the token totals are present.
|
||
"""
|
||
# 1e-6 / 2e-6 USD per input / output token.
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: ModelPricing(input_per_token=1e-6, output_per_token=2e-6),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"context_tokens": 1000,
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_output_tokens": 500,
|
||
"model": "databricks-gpt-5-5",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# Token totals SET from the cumulative values.
|
||
assert usage.get("input_tokens") == 1000
|
||
assert usage.get("output_tokens") == 500
|
||
assert usage.get("total_tokens") == 1500
|
||
# cost = 1000*1e-6 + 500*2e-6 = 0.001 + 0.001 = 0.002
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.002)
|
||
|
||
|
||
async def test_external_session_usage_codex_cached_tokens_priced_at_cache_rate(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
codex-native cached input is split out and priced at the cache-read rate.
|
||
|
||
Codex's ``cumulative_input_tokens`` is INCLUSIVE of cached tokens; the
|
||
server must subtract ``cumulative_cache_read_input_tokens`` and price that
|
||
portion at the (cheaper) cache-read rate. A failure means cached tokens are
|
||
billed at the full input rate — the over-report this fix targets.
|
||
"""
|
||
# Cache read is 10x cheaper than fresh input (1e-7 vs 1e-6).
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: ModelPricing(
|
||
input_per_token=1e-6,
|
||
output_per_token=2e-6,
|
||
cache_read_per_token=1e-7,
|
||
),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"context_tokens": 1000,
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_cache_read_input_tokens": 800,
|
||
"cumulative_output_tokens": 500,
|
||
"model": "gpt-5",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# input_tokens is the NON-cached remainder (1000 - 800); cache_read holds
|
||
# the split-out cached portion. If input_tokens were 1000 the split didn't
|
||
# happen and cached tokens would be billed at the full input rate.
|
||
assert usage.get("input_tokens") == 200
|
||
assert usage.get("cache_read_input_tokens") == 800
|
||
assert usage.get("output_tokens") == 500
|
||
# total reflects the FULL input (non-cached + cached) + output, unchanged
|
||
# by the split: 200 + 800 + 500 = 1500.
|
||
assert usage.get("total_tokens") == 1500
|
||
# cost = 200*1e-6 (non-cached) + 800*1e-7 (cache read) + 500*2e-6 (output)
|
||
# = 0.0002 + 0.00008 + 0.001 = 0.00128.
|
||
# Without the split it would be 0.002 (1000*1e-6 + 500*2e-6) — so this
|
||
# value being strictly below 0.002 proves the cache discount applied.
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.00128)
|
||
|
||
|
||
async def test_external_session_usage_codex_cached_tokens_no_catalog_cache_rate(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
With no published cache rate (today's ``databricks-*`` catalog entries),
|
||
the cached portion is split out AND priced at the derived ratio default
|
||
(0.10x input), so the dollar cost drops even though the catalog omits a
|
||
cache rate.
|
||
|
||
This is the ``databricks-*`` path: the catalog has input/output rates but
|
||
no ``cache_read_per_million_tokens``, so ``compute_llm_cost`` derives the
|
||
cache-read rate from the input rate. Before the ratio fallback this billed
|
||
cache reads at the full input rate (cost 0.002); now it bills 0.10x (cost
|
||
0.00128) — the over-charge this fixes.
|
||
"""
|
||
# No cache_read_per_token ⇒ compute_llm_cost derives it as 0.10x input.
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: ModelPricing(input_per_token=1e-6, output_per_token=2e-6),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"context_tokens": 1000,
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_cache_read_input_tokens": 800,
|
||
"cumulative_output_tokens": 500,
|
||
"model": "databricks-gpt-5-5",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage.get("input_tokens") == 200
|
||
assert usage.get("cache_read_input_tokens") == 800
|
||
assert usage.get("total_tokens") == 1500
|
||
# cost = 200*1e-6 (non-cached) + 800*(1e-6*0.10) (cache read at the derived
|
||
# ratio) + 500*2e-6 (output) = 0.0002 + 0.00008 + 0.001 = 0.00128.
|
||
# The old full-input fallback gave 0.002; 0.002 here means the ratio
|
||
# fallback regressed.
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.00128)
|
||
|
||
|
||
async def test_accumulate_session_usage_prices_from_usage_model(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A relay turn is priced from ``usage.model`` even when the spec pins no
|
||
priceable model.
|
||
|
||
This is the fix for delegating supervisors (e.g. debbie on claude-sdk) that
|
||
pin no ``llm.model``: the harness reports the model it actually used in
|
||
``usage.model`` and the relay cost path prices from it. Here only
|
||
``"harness-model"`` is priceable — the agent's spec model (its name) is
|
||
NOT — so a recorded cost proves the harness-reported model was used, not
|
||
the spec model.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: (
|
||
ModelPricing(input_per_token=1e-6, output_per_token=2e-6)
|
||
if model == "harness-model"
|
||
else None
|
||
),
|
||
)
|
||
agent = await create_test_agent(client) # spec llm.model == agent name (unpriced here)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{"usage": {"input_tokens": 1000, "output_tokens": 500, "model": "harness-model"}},
|
||
session["id"],
|
||
SqlAlchemyConversationStore(db_uri),
|
||
)
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# cost = 1000*1e-6 + 500*2e-6 = 0.002 — priced via usage.model despite the
|
||
# spec model being absent from the catalog.
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.002)
|
||
|
||
|
||
async def test_accumulate_session_usage_prefers_provider_cost(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A harness-reported ``cost_usd`` is used verbatim, overriding the catalog estimate.
|
||
|
||
Copilot reports the authoritative AI-credit cost it billed; the relay must
|
||
prefer that over recomputing from token counts x catalog pricing (which can
|
||
diverge, e.g. when the catalog lacks a cache-write rate).
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
# The catalog WOULD price this turn at 2.0 USD; the provider cost must win.
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: ModelPricing(input_per_token=1e-3, output_per_token=2e-3),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{
|
||
"usage": {
|
||
"input_tokens": 1000,
|
||
"output_tokens": 500,
|
||
"model": "harness-model",
|
||
"cost_usd": 0.01827875,
|
||
}
|
||
},
|
||
session["id"],
|
||
SqlAlchemyConversationStore(db_uri),
|
||
)
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
# Catalog would charge 1000*1e-3 + 500*2e-3 = 2.0; the provider cost wins.
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.01827875)
|
||
assert usage["by_model"]["harness-model"]["total_cost_usd"] == pytest.approx(0.01827875)
|
||
|
||
|
||
async def test_accumulate_session_usage_provider_cost_prices_uncatalogued_model(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A harness ``cost_usd`` makes a turn priced even when the catalog can't price it.
|
||
|
||
Without a catalog entry the token-price path leaves the turn unpriced; an
|
||
authoritative provider cost should still record ``total_cost_usd``.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: None, # catalog can't price anything
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{
|
||
"usage": {
|
||
"input_tokens": 10,
|
||
"output_tokens": 2,
|
||
"model": "grok-4.3",
|
||
"cost_usd": 0.0042,
|
||
}
|
||
},
|
||
session["id"],
|
||
SqlAlchemyConversationStore(db_uri),
|
||
)
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.0042)
|
||
assert usage["by_model"]["grok-4.3"]["total_cost_usd"] == pytest.approx(0.0042)
|
||
|
||
|
||
async def test_accumulate_session_usage_unpriced_without_usage_model(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""No ``usage.model`` and an unpriceable spec model ⇒ no cost recorded.
|
||
|
||
Guards the fallback chain and the "unpriced ⇒ no ``total_cost_usd`` key"
|
||
contract: when neither the harness nor the catalog can price the turn, the
|
||
cost key stays absent (so the UI shows "—", not a misleading $0.00).
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: None, # nothing is priceable
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{"usage": {"input_tokens": 1000, "output_tokens": 500}}, # no model in usage
|
||
session["id"],
|
||
SqlAlchemyConversationStore(db_uri),
|
||
)
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert "total_cost_usd" not in usage
|
||
|
||
|
||
async def test_accumulate_session_usage_records_per_model_breakdown(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Relay turns are attributed per model; per-model costs sum to the flat total.
|
||
|
||
Two turns on two different models must each land in their own ``by_model``
|
||
bucket with their own tokens, and the sum of per-model costs must equal the
|
||
flat session ``total_cost_usd`` — the no-double-count invariant the UI
|
||
relies on. If the per-model cost were attributed to the wrong model or
|
||
double-counted, this sum would diverge from the flat total.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: (
|
||
ModelPricing(input_per_token=1e-6, output_per_token=2e-6)
|
||
if model in {"model-a", "model-b"}
|
||
else None
|
||
),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
store = SqlAlchemyConversationStore(db_uri)
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{"usage": {"input_tokens": 1000, "output_tokens": 500, "model": "model-a"}},
|
||
session["id"],
|
||
store,
|
||
)
|
||
sessions_routes._accumulate_session_usage(
|
||
{"usage": {"input_tokens": 200, "output_tokens": 100, "model": "model-b"}},
|
||
session["id"],
|
||
store,
|
||
)
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
by_model = usage["by_model"]
|
||
# Each turn's tokens land in its own model bucket.
|
||
assert by_model["model-a"]["input_tokens"] == 1000
|
||
assert by_model["model-a"]["output_tokens"] == 500
|
||
assert by_model["model-b"]["input_tokens"] == 200
|
||
assert by_model["model-b"]["output_tokens"] == 100
|
||
# model-a: 1000*1e-6 + 500*2e-6 = 0.002 ; model-b: 200*1e-6 + 100*2e-6 = 0.0004.
|
||
assert by_model["model-a"]["total_cost_usd"] == pytest.approx(0.002)
|
||
assert by_model["model-b"]["total_cost_usd"] == pytest.approx(0.0004)
|
||
# Per-model costs sum to the flat total — proves no double-count / drop.
|
||
assert by_model["model-a"]["total_cost_usd"] + by_model["model-b"][
|
||
"total_cost_usd"
|
||
] == pytest.approx(usage["total_cost_usd"])
|
||
|
||
|
||
async def test_accumulate_session_usage_unpriced_model_has_tokens_no_cost(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""An unpriced relay model still records its tokens but no per-model cost key.
|
||
|
||
Mirrors the flat "priced ⟺ ``total_cost_usd`` key present" contract at the
|
||
per-model level: tokens are attributed even when the model isn't priceable
|
||
(so the token view is complete), but the model's bucket carries no cost key.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: None, # nothing priceable
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
sessions_routes._accumulate_session_usage(
|
||
{"usage": {"input_tokens": 1000, "output_tokens": 500, "model": "free-model"}},
|
||
session["id"],
|
||
SqlAlchemyConversationStore(db_uri),
|
||
)
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage["by_model"]["free-model"]["input_tokens"] == 1000
|
||
assert usage["by_model"]["free-model"]["output_tokens"] == 500
|
||
assert "total_cost_usd" not in usage["by_model"]["free-model"]
|
||
|
||
|
||
async def test_accumulate_session_usage_concurrent_calls_accumulate_both_deltas(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Concurrent _accumulate_session_usage calls each persist their full delta.
|
||
|
||
Regression guard for the read-modify-write lost-update bug (#9): the old
|
||
implementation read session_usage in one transaction and wrote back in
|
||
another, so two concurrent completions could each read the same stale total,
|
||
compute their own delta, and overwrite the other's result — silently dropping
|
||
a delta. The fix (increment_session_usage — single atomic transaction with
|
||
SELECT FOR UPDATE on PostgreSQL/MySQL) serialises concurrent writers so both
|
||
deltas are always preserved.
|
||
|
||
The calls are dispatched from two threads simultaneously via
|
||
concurrent.futures.ThreadPoolExecutor so the race window genuinely exists:
|
||
the old non-atomic implementation would fail this test non-deterministically
|
||
(or always, if the two reads are forced to coincide).
|
||
"""
|
||
import concurrent.futures
|
||
|
||
from omnigent.server.routes import sessions as sessions_routes
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: ModelPricing(input_per_token=1e-6, output_per_token=2e-6),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
def _call(input_tokens: int, output_tokens: int) -> None:
|
||
# Each thread gets its own store instance (its own DB connection) so
|
||
# the concurrency is real — not short-circuited by a shared connection.
|
||
store = SqlAlchemyConversationStore(db_uri)
|
||
sessions_routes._accumulate_session_usage(
|
||
{
|
||
"usage": {
|
||
"input_tokens": input_tokens,
|
||
"output_tokens": output_tokens,
|
||
"model": "m1",
|
||
}
|
||
},
|
||
session["id"],
|
||
store,
|
||
)
|
||
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||
f1 = pool.submit(_call, 1000, 500)
|
||
f2 = pool.submit(_call, 200, 100)
|
||
f1.result()
|
||
f2.result()
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage["input_tokens"] == 1200 # 1000 + 200
|
||
assert usage["output_tokens"] == 600 # 500 + 100
|
||
# cost = (1000+200)*1e-6 + (500+100)*2e-6 = 0.0012 + 0.0012 = 0.0024
|
||
assert usage.get("total_cost_usd") == pytest.approx(0.0024)
|
||
assert usage["by_model"]["m1"]["input_tokens"] == 1200
|
||
assert usage["by_model"]["m1"]["total_cost_usd"] == pytest.approx(0.0024)
|
||
|
||
|
||
async def test_external_session_usage_records_per_model_breakdown(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""A native cumulative usage POST attributes its buckets to the event's model.
|
||
|
||
Native harnesses report cumulative SESSION totals (SET semantics), so the
|
||
per-model bucket for the event's ``model`` mirrors the flat cumulative
|
||
buckets. Covers the native write path's per-model capture end-to-end
|
||
through the HTTP endpoint.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_output_tokens": 500,
|
||
"cumulative_cost_usd": 0.42,
|
||
"model": "native-model",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
bucket = usage["by_model"]["native-model"]
|
||
# No cache split here, so input_tokens == cumulative input; total folds
|
||
# input + output. Cost mirrors the flat (display) total.
|
||
assert bucket["input_tokens"] == 1000
|
||
assert bucket["output_tokens"] == 500
|
||
assert bucket["total_tokens"] == 1500
|
||
assert bucket["total_cost_usd"] == pytest.approx(0.42)
|
||
|
||
|
||
async def test_external_session_usage_cost_only_attributes_to_model(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""A claude-native COST-ONLY broadcast attributes its cost to ``by_model``.
|
||
|
||
claude-native forwards Claude Code's statusLine total (S) with NO token
|
||
counts, tagging it with the active ``model``. Before this fix the per-model
|
||
view was gated on tokens, so a cost-only broadcast dropped its cost from
|
||
``by_model`` entirely — the TOKEN USAGE panel undercounted the session
|
||
total by every native (sub-)agent's spend while the flat ``total_cost_usd``
|
||
still included it. The cost must now land in the model's bucket so the
|
||
per-model costs reconcile with the flat total (the UI's no-drop invariant).
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 0.42, "model": "claude-opus-4-8"},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
bucket = usage["by_model"]["claude-opus-4-8"]
|
||
# Cost attributed to the model; no token counts (claude-native reports none).
|
||
assert bucket["total_cost_usd"] == pytest.approx(0.42)
|
||
assert "input_tokens" not in bucket
|
||
# Per-model cost reconciles with the flat session total — the exact gap
|
||
# this fix closes ($Session-cost == sum of per-model costs).
|
||
assert bucket["total_cost_usd"] == pytest.approx(usage["total_cost_usd"])
|
||
|
||
|
||
async def test_external_session_usage_cost_only_falls_back_to_model_override(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""Cost-only attribution falls back to the session's ``model_override``.
|
||
|
||
claude-native's cost broadcast omits ``model`` until the statusLine has
|
||
captured it, but the forwarder mirrors the in-pane active model to
|
||
``model_override`` each poll. The native write path must consult it (as the
|
||
relay path does) so the cost is still attributed per-model rather than
|
||
silently dropped from the TOKEN USAGE view.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
# Simulate the forwarder having mirrored the active model to model_override.
|
||
SqlAlchemyConversationStore(db_uri).update_conversation(
|
||
session["id"], model_override="claude-sonnet-4-6"
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 0.10},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage["by_model"]["claude-sonnet-4-6"]["total_cost_usd"] == pytest.approx(0.10)
|
||
|
||
|
||
async def test_external_session_usage_policy_cost_only_skips_attribution(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""A ``policy_cost_usd``-only mid-turn post records no per-model bucket.
|
||
|
||
Mid-sub-agent-run the displayed statusLine total is frozen, so the
|
||
forwarder posts only the advancing enforcement cost (no
|
||
``cumulative_cost_usd``, no tokens). There is no DISPLAY cost to attribute,
|
||
so attribution is skipped — only the priced display cost flows into
|
||
``by_model`` (and the badge), keeping the per-model view = the flat
|
||
``total_cost_usd`` rather than the higher in-flight gate estimate.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"policy_cost_usd": 0.50}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert "by_model" not in usage
|
||
assert usage.get("policy_cost_usd") == pytest.approx(0.50)
|
||
|
||
|
||
async def test_external_session_usage_event_carries_priced_cost(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A priced session's ``session.usage`` event carries ``total_cost_usd``.
|
||
|
||
The web cost indicator reads the cumulative spend off this event. When
|
||
the session is priced (here: claude-native exact billing), the
|
||
server-computed total must ride on the broadcast so the indicator
|
||
updates live without waiting for a snapshot reload.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 100, "cumulative_cost_usd": 0.42},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert [event["type"] for _, event in published] == ["session.usage"]
|
||
assert published[0][1]["total_cost_usd"] == 0.42
|
||
|
||
|
||
async def test_external_session_usage_event_carries_token_breakdown(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A native ``session.usage`` event carries the per-bucket token breakdown.
|
||
|
||
The web token-breakdown rows read these cumulative subtree counts off the
|
||
broadcast so they update live alongside the USD cost. The buckets are
|
||
persisted from the ``cumulative_*`` fields, then surfaced under their
|
||
canonical keys (``input_tokens`` etc.).
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"context_tokens": 1000,
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_output_tokens": 500,
|
||
"cumulative_cost_usd": 0.42,
|
||
"model": "test-model",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert [event["type"] for _, event in published] == ["session.usage"]
|
||
event = published[0][1]
|
||
# The per-model breakdown rides the broadcast, keyed by the event's model.
|
||
assert event["usage_by_model"]["test-model"]["input_tokens"] == 1000
|
||
assert event["usage_by_model"]["test-model"]["output_tokens"] == 500
|
||
|
||
|
||
async def test_external_session_usage_unpriced_omits_cost(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
An unpriced session omits ``total_cost_usd`` everywhere — event and store.
|
||
|
||
When the model isn't in the pricing catalog, the server records tokens
|
||
but no cost. The ``total_cost_usd`` key must stay **absent** from
|
||
``session_usage`` (its presence is the "priced" signal) and the
|
||
``session.usage`` event must not carry it, so the web UI renders "—"
|
||
rather than a misleading ``$0.00``. Contrast with the policy gate,
|
||
which reads ``total_cost_usd`` with a ``0.0`` default and is unaffected
|
||
by the absence.
|
||
"""
|
||
# Pricing unavailable for the model — the unpriced path.
|
||
monkeypatch.setattr(
|
||
"omnigent.llms.context_window.fetch_model_pricing",
|
||
lambda model: None,
|
||
)
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {
|
||
"context_tokens": 1000,
|
||
"cumulative_input_tokens": 1000,
|
||
"cumulative_output_tokens": 500,
|
||
"model": "an-unpriced-model",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# Tokens recorded, but no cost key written.
|
||
usage = _read_session_usage(db_uri, session["id"])
|
||
assert usage.get("total_tokens") == 1500
|
||
assert "total_cost_usd" not in usage
|
||
# The broadcast carries no cost field either.
|
||
assert [event["type"] for _, event in published] == ["session.usage"]
|
||
assert "total_cost_usd" not in published[0][1]
|
||
|
||
|
||
async def test_session_snapshot_includes_priced_cost(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
The session snapshot seeds the cost indicator with the priced total.
|
||
|
||
On reload the web client reads ``total_cost_usd`` off the
|
||
``GET /v1/sessions/{id}`` snapshot (the ``session.usage`` stream has no
|
||
replay). A priced session must surface its cumulative cost there.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 100, "cumulative_cost_usd": 0.42},
|
||
},
|
||
)
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["total_cost_usd"] == 0.42
|
||
|
||
|
||
async def test_session_snapshot_unpriced_cost_is_none(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
An unpriced session's snapshot reports ``total_cost_usd`` as ``None``.
|
||
|
||
A freshly created session has recorded no priced spend, so the snapshot
|
||
must carry ``None`` (rendered "—" by the client) rather than ``0.0`` —
|
||
the explicit unpriced signal that keeps the UI from implying the
|
||
session was free.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["total_cost_usd"] is None
|
||
|
||
|
||
async def test_external_session_usage_rejects_malformed_cumulative(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A non-numeric ``cumulative_cost_usd`` is rejected with 400 (fail loud).
|
||
|
||
Guards against a forwarder bug silently corrupting persisted cost.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"context_tokens": 100, "cumulative_cost_usd": "lots"},
|
||
},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "cumulative_cost_usd" in resp.text
|
||
|
||
|
||
# Cost-budget policy. Enforcement moved to the ``tool_call`` gate (the
|
||
# PreToolUse hook) — see omnigent/policies/builtins/cost.py. The old
|
||
# post-hoc ``output_logged`` path that stopped the whole session is gone:
|
||
# an over-budget tool call is DENYed (prompting a /model downgrade), while
|
||
# logged usage on its own never stops the session.
|
||
_COST_GUARD_GUARDRAILS = {
|
||
"policies": {
|
||
"cost_guard": {
|
||
"type": "function",
|
||
"function": {
|
||
"path": "omnigent.policies.builtins.cost.cost_budget",
|
||
"arguments": {"max_cost_usd": 1.0},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
async def test_external_session_usage_over_budget_does_not_stop_session(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""Over-budget cumulative usage is recorded but never stops the session.
|
||
|
||
The cost budget enforces at the ``tool_call`` gate now, not via the
|
||
removed post-hoc ``output_logged`` hook. Posting
|
||
``external_session_usage`` over the limit must still 202 and persist
|
||
the cost (so the tool-call gate can read it on the next tool call) but
|
||
must NOT set the deliberate-stop label — a regression re-adding a
|
||
session-stop here would silently kill sessions on spend.
|
||
"""
|
||
agent = await create_test_agent(client, guardrails=_COST_GUARD_GUARDRAILS)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_session_usage",
|
||
"data": {"cumulative_cost_usd": 2.5}, # over the $1.00 limit
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
conv = SqlAlchemyConversationStore(db_uri).get_conversation(session["id"])
|
||
assert conv is not None
|
||
# Cost persisted so the tool-call gate can read it on the next tool call.
|
||
assert conv.session_usage.get("total_cost_usd") == 2.5
|
||
# ...but the session is NOT stopped (the post-hoc cost stop is gone).
|
||
assert conv.labels.get("omnigent.stopped") != "true"
|
||
|
||
|
||
# Cost guard with a soft warning checkpoint, for the relay tool-call ASK
|
||
# (non-native) approval path: crossing $0.05 ASKs once; approving must record
|
||
# it so it does not re-prompt.
|
||
_COST_GUARD_SOFT_GUARDRAILS = {
|
||
"policies": {
|
||
"cost_guard": {
|
||
"type": "function",
|
||
"function": {
|
||
"path": "omnigent.policies.builtins.cost.cost_budget",
|
||
"arguments": {"max_cost_usd": 1.0, "ask_thresholds_usd": [0.05]},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
async def _evaluate_tool(client: httpx.AsyncClient, sid: str) -> dict[str, Any]:
|
||
"""Run a relay tool-call policy query (the non-native gate) and return the verdict.
|
||
|
||
Mirrors how the relay asks the server "may I run this tool?": a
|
||
``function_call`` event carrying ``evaluate_policy`` (a query, not an
|
||
item to persist). ``agent`` / ``call_id`` are required ``FunctionCallData``
|
||
fields; the policy eval keys off ``conv.agent_id``, not these.
|
||
|
||
:param client: Test HTTP client.
|
||
:param sid: Session id.
|
||
:returns: The verdict dict, e.g. ``{"verdict": "pending", ...}`` or
|
||
``{"verdict": "allow"}``.
|
||
"""
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={
|
||
"type": "function_call",
|
||
"data": {
|
||
"evaluate_policy": True,
|
||
"name": "sys_os_shell",
|
||
"arguments": "{}",
|
||
"agent": "test-agent",
|
||
"call_id": f"call_{uuid.uuid4().hex}",
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code < 300, resp.text
|
||
return resp.json()
|
||
|
||
|
||
async def test_relay_tool_call_ask_approval_persists_checkpoint(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""Approving a relay tool-call ASK records the checkpoint so it stops re-asking.
|
||
|
||
The non-native (relay) tool-call gate parks a soft-threshold ASK as a
|
||
runner-owned elicitation (verdict ``pending``); the verdict arrives later
|
||
via the ``approval`` event. The deciding policy's ``state_updates`` (the
|
||
crossed cost checkpoint) must be persisted ON ACCEPT — otherwise the
|
||
checkpoint is never recorded and every later tool call re-asks (the bug
|
||
this guards; the native path persisted it but the relay path dropped it).
|
||
"""
|
||
from omnigent.policies.builtins.cost import _ASK_APPROVED_KEY
|
||
|
||
agent = await create_test_agent(client, guardrails=_COST_GUARD_SOFT_GUARDRAILS)
|
||
session = await _create_session(client, agent["id"])
|
||
sid = session["id"]
|
||
store = SqlAlchemyConversationStore(db_uri)
|
||
|
||
# Push spend past the $0.05 soft checkpoint (still under the $1.00 limit).
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={"type": "external_session_usage", "data": {"cumulative_cost_usd": 0.1}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
# First tool-call query → ASK parked as a pending elicitation.
|
||
verdict = await _evaluate_tool(client, sid)
|
||
assert verdict["verdict"] == "pending", verdict
|
||
eid = verdict["elicitation_id"]
|
||
|
||
# Checkpoint not recorded yet — it must land only on approve.
|
||
assert (store.get_conversation(sid).session_state or {}).get(_ASK_APPROVED_KEY) is None
|
||
|
||
# Approve → the deciding policy's checkpoint must persist server-side.
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={"type": "approval", "data": {"elicitation_id": eid, "action": "accept"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# 0.05 = the crossed checkpoint recorded on approve. None/0.0 here is the
|
||
# bug: the relay approval dropped the state_updates, so it re-asks forever.
|
||
recorded = (store.get_conversation(sid).session_state or {}).get(_ASK_APPROVED_KEY)
|
||
assert recorded == 0.05, f"checkpoint not recorded on approve: {recorded!r}"
|
||
|
||
# Re-evaluating no longer re-asks (pre-fix this returned ``pending`` again).
|
||
assert (await _evaluate_tool(client, sid)).get("verdict") == "allow"
|
||
|
||
|
||
async def test_relay_tool_call_ask_decline_does_not_record_checkpoint(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""A declined relay tool-call ASK leaves the checkpoint unrecorded.
|
||
|
||
POLICIES.md §7.2: a denied ASK leaves no trace. So a decline must NOT
|
||
persist the checkpoint — the next tool call re-asks (the user did not
|
||
consent to continue past the threshold).
|
||
"""
|
||
from omnigent.policies.builtins.cost import _ASK_APPROVED_KEY
|
||
|
||
agent = await create_test_agent(client, guardrails=_COST_GUARD_SOFT_GUARDRAILS)
|
||
session = await _create_session(client, agent["id"])
|
||
sid = session["id"]
|
||
store = SqlAlchemyConversationStore(db_uri)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={"type": "external_session_usage", "data": {"cumulative_cost_usd": 0.1}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
verdict = await _evaluate_tool(client, sid)
|
||
assert verdict["verdict"] == "pending", verdict
|
||
eid = verdict["elicitation_id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={"type": "approval", "data": {"elicitation_id": eid, "action": "decline"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# Declined → checkpoint NOT recorded; the next tool call re-asks.
|
||
assert (store.get_conversation(sid).session_state or {}).get(_ASK_APPROVED_KEY) is None
|
||
assert (await _evaluate_tool(client, sid)).get("verdict") == "pending"
|
||
|
||
|
||
async def test_mcp_relay_tool_call_ask_approval_persists_checkpoint(
|
||
client: httpx.AsyncClient,
|
||
db_uri: str,
|
||
) -> None:
|
||
"""Approving an MCP relay ``tools/call`` ASK records the checkpoint (no re-prompt).
|
||
|
||
The MCP proxy gate parks a soft-threshold ASK as an ``input_required``
|
||
result; the client re-sends (retry) carrying the approval. The deciding
|
||
policy's ``state_updates`` (the cost checkpoint) must be applied on that
|
||
approved retry — otherwise it is dropped and re-prompts every call. This
|
||
is the path openai-agents / MCP-relay sessions actually use (distinct from
|
||
the native ``/policies/evaluate`` and the ``function_call`` query paths).
|
||
The retry's tool execution fails here (no runner bound), but the policy
|
||
write is applied before execution, which is what we assert.
|
||
"""
|
||
from omnigent.policies.builtins.cost import _ASK_APPROVED_KEY
|
||
|
||
agent = await create_test_agent(client, guardrails=_COST_GUARD_SOFT_GUARDRAILS)
|
||
session = await _create_session(client, agent["id"])
|
||
sid = session["id"]
|
||
store = SqlAlchemyConversationStore(db_uri)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={"type": "external_session_usage", "data": {"cumulative_cost_usd": 0.1}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
# First tools/call → over the $0.05 checkpoint → ASK (input_required).
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/mcp",
|
||
json={
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "tools/call",
|
||
"params": {"name": "mcp__test__echo", "arguments": {}},
|
||
},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
result = resp.json()["result"]
|
||
assert result["resultType"] == "input_required", result
|
||
request_state = result["requestState"]
|
||
eid = next(iter(result["inputRequests"]))
|
||
|
||
# Not recorded until approve.
|
||
assert (store.get_conversation(sid).session_state or {}).get(_ASK_APPROVED_KEY) is None
|
||
|
||
# Retry carrying the approval → the deferred checkpoint must persist
|
||
# (the subsequent tool exec errors for lack of a runner, after the write).
|
||
await client.post(
|
||
f"/v1/sessions/{sid}/mcp",
|
||
json={
|
||
"jsonrpc": "2.0",
|
||
"id": 2,
|
||
"method": "tools/call",
|
||
"params": {
|
||
"name": "mcp__test__echo",
|
||
"arguments": {},
|
||
"requestState": request_state,
|
||
"inputResponses": {eid: {"action": "accept"}},
|
||
},
|
||
},
|
||
)
|
||
recorded = (store.get_conversation(sid).session_state or {}).get(_ASK_APPROVED_KEY)
|
||
assert recorded == 0.05, f"checkpoint not recorded on MCP approve: {recorded!r}"
|
||
|
||
|
||
async def test_post_external_model_change_publishes_session_model(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_model_change`` persists ``model_override`` and posts a
|
||
typed SessionModelEvent.
|
||
|
||
The claude-native forwarder posts this when the user switches model
|
||
inside the Claude Code terminal (``/model`` command or in-TUI
|
||
picker), so the web picker reflects it live (the SSE event) and on
|
||
reload (the persisted override). Asserts the exact published event
|
||
type + payload and the persisted snapshot value.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_model_change", "data": {"model": "opus"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
assert [event["type"] for _, event in published] == ["session.model"]
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert published[0][1]["model"] == "opus"
|
||
|
||
# Persisted so a reload restores the picker selection.
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["model_override"] == "opus"
|
||
|
||
|
||
async def test_post_external_model_change_dedupes_when_unchanged(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A repeat ``external_model_change`` for the already-persisted model
|
||
is a no-op: no second ``session.model`` event, no redundant write.
|
||
|
||
This is the web→TUI round-trip — the web PATCH set ``model_override``
|
||
to ``"opus"`` and injected ``/model opus``, then the forwarder
|
||
echoes the resulting transcript model back. Without server-side
|
||
dedupe the picker would re-render on its own write.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# Seed the override the way a web picker PATCH would (silent: no
|
||
# runner forward needed for the test session).
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "opus", "silent": True},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
published.clear()
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_model_change", "data": {"model": "opus"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# Already on "opus" — nothing re-published.
|
||
assert [event["type"] for _, event in published] == []
|
||
|
||
|
||
async def test_post_external_model_change_rejects_empty_model(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A whitespace-only / missing ``data.model`` 400s.
|
||
|
||
Fail loud rather than persist a blank override that would blank the
|
||
picker selection.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_model_change", "data": {"model": " "}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_model_change" in resp.text
|
||
|
||
|
||
async def test_post_external_model_change_does_not_forward_to_runner(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_model_change`` must NOT re-inject ``/model`` into the runner.
|
||
|
||
The terminal is already on the model, so forwarding a ``model_change``
|
||
back would loop (runner injects ``/model`` → transcript records the
|
||
model → forwarder posts ``external_model_change`` → ...). This guards
|
||
that loop boundary — note the contrast with the PATCH path
|
||
(``update_session``), which DOES forward ``model_change`` to the
|
||
runner. The ``session.model`` SSE still fires; the runner sees nothing.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
runner_paths: list[str] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record the path of anything routed to the runner; accept all."""
|
||
runner_paths.append(request.url.path)
|
||
return httpx.Response(202, json={})
|
||
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
runner_paths.clear() # ignore any bind-time runner traffic
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_model_change", "data": {"model": "sonnet"}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# The web picker is nudged...
|
||
assert "session.model" in [event["type"] for _, event in published]
|
||
# ...but nothing was forwarded to the runner (no /model re-injection loop).
|
||
assert runner_paths == [], (
|
||
f"external_model_change must not call the runner; got {runner_paths}"
|
||
)
|
||
|
||
|
||
async def test_post_external_reasoning_effort_change_publishes_session_effort(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_reasoning_effort_change`` persists effort and posts SSE.
|
||
|
||
This is the Codex TUI-side thinking-level path. The route must update
|
||
``conversation.reasoning_effort`` for reload/cost resolution and publish a
|
||
typed live event so the web picker follows the terminal immediately.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_reasoning_effort_change",
|
||
"data": {"reasoning_effort": "medium"},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
assert [event["type"] for _, event in published] == ["session.reasoning_effort"]
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert published[0][1]["reasoning_effort"] == "medium"
|
||
# Persisted snapshot proves this was not just a transient SSE update.
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["reasoning_effort"] == "medium"
|
||
|
||
|
||
async def test_post_external_reasoning_effort_change_clears_effort(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_reasoning_effort_change`` with null clears stale effort.
|
||
|
||
Codex reports ``effort: null`` when the session is back on its default
|
||
thinking level. If the route treated null as "omitted", a previous explicit
|
||
``reasoning_effort`` would survive incorrectly.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
seed = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "high", "silent": True},
|
||
)
|
||
assert seed.status_code == 200, seed.text
|
||
published.clear()
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_reasoning_effort_change",
|
||
"data": {"reasoning_effort": None},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 202, resp.text
|
||
assert [event["type"] for _, event in published] == ["session.reasoning_effort"]
|
||
assert published[0][1]["reasoning_effort"] is None
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
# Null from Codex must clear the stored override, not leave "high" behind.
|
||
assert snapshot["reasoning_effort"] is None
|
||
|
||
|
||
async def test_post_external_reasoning_effort_change_rejects_invalid_effort(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Unsupported terminal-observed effort values fail loud.
|
||
|
||
This prevents a malformed Codex event from persisting a value the session
|
||
PATCH path and frontend picker do not understand.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_reasoning_effort_change",
|
||
"data": {"reasoning_effort": "turbo"},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 400, resp.text
|
||
assert "invalid reasoning_effort" in resp.text
|
||
|
||
|
||
async def test_post_external_codex_collaboration_mode_change_persists_label(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Codex collaboration mode mirrors into the session labels.
|
||
|
||
The app-server mode is the only "Plan vs Default" state Codex exposes.
|
||
Persisting it as a label lets session snapshots report the current Codex
|
||
mode without adding a Codex-specific conversation column.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_codex_collaboration_mode_change",
|
||
"data": {"mode": "plan"},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 202, resp.text
|
||
assert [event["type"] for _, event in published] == ["session.collaboration_mode"]
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert published[0][1]["mode"] == "plan"
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["labels"]["omnigent.codex_native.collaboration_mode"] == "plan"
|
||
|
||
|
||
async def test_post_external_codex_collaboration_mode_change_rejects_unknown_mode(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Unknown Codex collaboration mode kinds fail instead of becoming labels."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_codex_collaboration_mode_change",
|
||
"data": {"mode": "review"},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_codex_collaboration_mode_change" in resp.text
|
||
|
||
|
||
def _model_change_notes(published: list[tuple[str, dict[str, Any]]]) -> list[str]:
|
||
"""
|
||
Extract ``[System: ...]`` model-change note texts from published events.
|
||
|
||
The note is appended as a user-role message and broadcast via a
|
||
``session.input.consumed`` event (see ``_publish_input_consumed``), so
|
||
its text lives at ``event["data"]["data"]["content"][0]["text"]``.
|
||
|
||
:param published: ``(session_id, event_dict)`` tuples captured from a
|
||
monkeypatched ``session_stream.publish``.
|
||
:returns: The text of every consumed user message that reads as a
|
||
``[System: ...]`` marker (model-change notes are the only ones the
|
||
PATCH path emits).
|
||
"""
|
||
notes: list[str] = []
|
||
for _, event in published:
|
||
if event.get("type") != "session.input.consumed":
|
||
continue
|
||
data = event.get("data", {}).get("data", {})
|
||
content = data.get("content") if isinstance(data, dict) else None
|
||
if isinstance(content, list) and content and isinstance(content[0], dict):
|
||
text = content[0].get("text", "")
|
||
if text.startswith("[System:"):
|
||
notes.append(text)
|
||
return notes
|
||
|
||
|
||
async def test_patch_model_override_records_system_note_for_inprocess_session(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A web/REPL ``/model`` PATCH on a non-native session appends a durable
|
||
``[System: model changed to X]`` transcript note.
|
||
|
||
This is the visible record the composer no longer shows inline: it
|
||
persists, survives reload, and renders centered+muted in the web UI.
|
||
Asserts the exact note text reached the session stream.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
async def _noop_forward(*_args: Any, **_kwargs: Any) -> None:
|
||
"""Isolate the note logic from the live runner forward."""
|
||
return
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions._forward_session_change_to_runner",
|
||
_noop_forward,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "databricks-gpt-5-4"},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
|
||
# Exactly one note, naming the new model. A missing note would mean the
|
||
# gate dropped an in-process session; a wrong string means the text
|
||
# template drifted from what SystemMessageView keys on.
|
||
assert _model_change_notes(published) == ["[System: model changed to databricks-gpt-5-4]"]
|
||
|
||
|
||
async def test_patch_model_override_clear_records_reset_note(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Clearing the override (``default``) records a reset note, not a model name."""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
async def _noop_forward(*_args: Any, **_kwargs: Any) -> None:
|
||
"""Isolate the note logic from the live runner forward."""
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions._forward_session_change_to_runner",
|
||
_noop_forward,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "default"}, # REPL/web clear sentinel
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
assert _model_change_notes(published) == ["[System: model reset to the agent default]"]
|
||
|
||
|
||
async def test_patch_model_override_skips_note_for_native_session(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A native-wrapper session (``omnigent.wrapper`` set, here alongside
|
||
``omnigent.ui == "terminal"``) must NOT get an injected note —
|
||
claude-native uses the picker and codex-native pins its model at launch,
|
||
so an AP-side ``[System: ...]`` item would be a stray/misleading record
|
||
(and pollute the mirrored transcript). The gate keys on the wrapper
|
||
label, NOT ``omnigent.ui`` alone — see
|
||
``test_patch_model_override_records_note_for_terminal_view_sdk_session``
|
||
for the chat-first SDK session that has ``omnigent.ui`` but no wrapper
|
||
and DOES get the note.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
async def _noop_forward(*_args: Any, **_kwargs: Any) -> None:
|
||
"""Isolate the note logic from the live runner forward."""
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions._forward_session_change_to_runner",
|
||
_noop_forward,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "opus"},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
# Gate excludes native-wrapper sessions — no transcript note.
|
||
assert _model_change_notes(published) == []
|
||
|
||
|
||
async def test_patch_model_override_records_note_for_terminal_view_sdk_session(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A chat-first SDK session that merely exposes a REPL terminal view
|
||
(``omnigent.ui == "terminal"`` but NO ``omnigent.wrapper``) DOES get the
|
||
note.
|
||
|
||
This is the polly / debby case: when such an agent is launched via
|
||
``omnigent run``, the runner stamps ``omnigent.ui: terminal`` to enable
|
||
the web Chat/Terminal toggle (runner ``app.py``), but the brain is an
|
||
in-process claude-sdk agent whose history Omnigent writes — so a web
|
||
``/model`` switch should land a durable ``[System: ...]`` note. Gating on
|
||
``omnigent.ui`` (the pre-fix behavior) wrongly suppressed it; the gate
|
||
must key on the ``omnigent.wrapper`` native label instead.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
async def _noop_forward(*_args: Any, **_kwargs: Any) -> None:
|
||
"""Isolate the note logic from the live runner forward."""
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions._forward_session_change_to_runner",
|
||
_noop_forward,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
# Terminal VIEW only — no native wrapper. Mirrors a polly/debby
|
||
# session launched via `omnigent run`.
|
||
labels={"omnigent.ui": "terminal"},
|
||
)
|
||
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "databricks-claude-sonnet-4-6"},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
# Note IS recorded: ``omnigent.ui == "terminal"`` alone must not suppress
|
||
# it. An empty list here means the gate regressed to keying on
|
||
# ``omnigent.ui``, re-breaking the polly/debby web ``/model`` feedback.
|
||
assert _model_change_notes(published) == [
|
||
"[System: model changed to databricks-claude-sonnet-4-6]"
|
||
]
|
||
|
||
|
||
async def test_patch_model_override_silent_skips_note(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A ``silent`` PATCH (bind-time auto-apply) must NOT record a note — only
|
||
an explicit ``/model`` command should leave a transcript marker.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
patch = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"model_override": "databricks-gpt-5-4", "silent": True},
|
||
)
|
||
assert patch.status_code == 200, patch.text
|
||
assert _model_change_notes(published) == []
|
||
|
||
|
||
async def test_post_external_session_usage_rejects_negative_context_tokens(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Negative or non-int ``context_tokens`` is rejected with a 400.
|
||
|
||
Defends web's ring math (``pct = tokensUsed / contextWindow``)
|
||
from inheriting a bogus negative numerator that would clamp the
|
||
arc to zero and silently mislead users about their context budget.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_usage", "data": {"context_tokens": -1}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_session_usage" in resp.text
|
||
|
||
|
||
async def test_post_external_session_todos_publishes_session_todos(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_todos`` publishes a ``session.todos`` SSE event.
|
||
|
||
The claude-native forwarder posts this on every PostToolUse / TodoWrite
|
||
hook so the web todo panel updates in real time. A regression here
|
||
would break the panel for ``omnigent claude`` sessions: the UI would
|
||
never receive a ``session.todos`` broadcast and the panel would stay
|
||
blank even when Claude has active tasks.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
todos = [
|
||
{"content": "Write tests", "status": "completed", "activeForm": "Doing it"},
|
||
{"content": "Fix the bug", "status": "in_progress", "activeForm": "Doing it"},
|
||
{"content": "Review PR", "status": "pending", "activeForm": "Doing it"},
|
||
]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
# Exactly one session.todos event must be published; no extra events.
|
||
assert [ev["type"] for _, ev in published] == ["session.todos"]
|
||
assert published[0][0] == session["id"]
|
||
# The full todos list is forwarded verbatim to subscribers.
|
||
assert published[0][1]["todos"] == todos
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
|
||
|
||
async def test_post_external_session_todos_updates_snapshot(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``external_session_todos`` persists the list in the in-memory cache so
|
||
the snapshot returned by GET /v1/sessions/{id} reflects it.
|
||
|
||
The root bug this tests: ``_EXTERNAL_SESSION_TODOS_TYPE`` was missing
|
||
from ``_ALLOWED_EVENT_TYPES``, so every POST was rejected with a 400
|
||
before ``_handle_external_session_todos`` could populate the cache.
|
||
As a result the snapshot always returned ``todos: []`` even when
|
||
Claude had active tasks.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||
|
||
todos = [
|
||
{"content": "Do something", "status": "in_progress", "activeForm": "Doing it"},
|
||
]
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_todos", "data": {"todos": todos}},
|
||
)
|
||
assert resp.status_code in (200, 202), resp.text
|
||
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
# The snapshot todos field must match exactly what was posted.
|
||
# A failure here means _session_todos_cache was not populated (the
|
||
# original bug), or the snapshot builder ignores the cache.
|
||
assert snapshot["todos"] == todos
|
||
finally:
|
||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_post_external_session_todos_empty_list_clears_snapshot(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
An empty ``todos`` list is valid and overwrites the previous cache entry.
|
||
|
||
Claude posts an empty list when all tasks are done. The panel should
|
||
disappear (renders nothing on empty); the snapshot must reflect the
|
||
cleared list so a page refresh also shows the empty state.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
sessions_module._session_todos_cache[session["id"]] = [
|
||
{"content": "Old task", "status": "pending", "activeForm": "Doing it"}
|
||
]
|
||
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_todos", "data": {"todos": []}},
|
||
)
|
||
assert resp.status_code in (200, 202), resp.text
|
||
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
# Empty list must replace the previous cache entry, not be ignored.
|
||
assert snapshot["todos"] == []
|
||
finally:
|
||
sessions_module._session_todos_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_post_external_session_todos_rejects_missing_todos(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Payloads missing ``data.todos`` are rejected with a 400.
|
||
|
||
Without this guard a malformed forwarder post would silently pass
|
||
without updating the panel, making the bug invisible to the sender.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_todos", "data": {}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_session_todos" in resp.text
|
||
|
||
|
||
async def test_post_external_session_todos_rejects_non_list_todos(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A non-list ``data.todos`` value is rejected with a 400.
|
||
|
||
The handler asserts ``isinstance(todos, list)`` before caching;
|
||
a dict or string would corrupt the cache and produce a malformed
|
||
``session.todos`` SSE payload downstream.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_session_todos", "data": {"todos": "not a list"}},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "external_session_todos" in resp.text
|
||
|
||
|
||
async def test_post_external_mcp_startup_publishes_session_mcp_startup(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_mcp_startup`` publishes a ``session.mcp_startup`` SSE event.
|
||
|
||
The codex-native forwarder posts the full per-server map on every MCP
|
||
startup edge so the web UI can show which servers are still starting
|
||
instead of an apparently hung session (issue #2058). A regression here
|
||
leaves the session silent while Codex boots slow or failing MCP
|
||
servers.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
servers = {
|
||
"safe": {"status": "failed", "error": "handshake failed"},
|
||
"storage-console": {"status": "starting", "error": None},
|
||
}
|
||
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_mcp_startup", "data": {"servers": servers}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert resp.json() == {"queued": False}
|
||
|
||
# Exactly one session.mcp_startup event, carrying the full map.
|
||
assert [ev["type"] for _, ev in published] == ["session.mcp_startup"]
|
||
assert published[0][0] == session["id"]
|
||
assert published[0][1]["conversation_id"] == session["id"]
|
||
assert published[0][1]["servers"] == servers
|
||
|
||
# The snapshot replays the map so a client opening the session
|
||
# mid-startup seeds the band without waiting for the next event.
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["mcp_startup"] == servers
|
||
|
||
# An empty (settled) map evicts the cache — the snapshot goes back
|
||
# to carrying no startup state instead of a stale settled map.
|
||
settled = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_mcp_startup", "data": {"servers": {}}},
|
||
)
|
||
assert settled.status_code == 202, settled.text
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["mcp_startup"] is None
|
||
finally:
|
||
sessions_module._session_mcp_startup_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_post_external_mcp_startup_all_ready_evicts_snapshot_cache(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
An all-``ready`` map clears the snapshot cache like an empty one.
|
||
|
||
The web store clears the startup band once every server settles
|
||
ready, so a snapshot that kept replaying an all-ready map would make
|
||
a reloading client suppress its empty state for a band that renders
|
||
nothing — a blank conversation area.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
try:
|
||
starting = {"safe": {"status": "starting", "error": None}}
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_mcp_startup", "data": {"servers": starting}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["mcp_startup"] == starting
|
||
|
||
all_ready = {"safe": {"status": "ready", "error": None}}
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_mcp_startup", "data": {"servers": all_ready}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# The live event still carries the map (the store clears on it),
|
||
# but the snapshot stops replaying it.
|
||
assert published[-1][1]["servers"] == all_ready
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
assert snapshot["mcp_startup"] is None
|
||
finally:
|
||
sessions_module._session_mcp_startup_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_delete_session_evicts_mcp_startup_cache(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Deleting a session drops its MCP-startup snapshot-cache entry.
|
||
|
||
A round that settles with failed/cancelled servers leaves a
|
||
non-empty map in the cache (retained for reload visibility); without
|
||
the DELETE eviction every such session would leak one entry for the
|
||
process lifetime.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
|
||
try:
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_mcp_startup",
|
||
"data": {"servers": {"safe": {"status": "cancelled", "error": None}}},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
assert session["id"] in sessions_module._session_mcp_startup_cache
|
||
|
||
resp = await client.delete(f"/v1/sessions/{session['id']}")
|
||
assert resp.status_code == 200, resp.text
|
||
assert session["id"] not in sessions_module._session_mcp_startup_cache
|
||
finally:
|
||
sessions_module._session_mcp_startup_cache.pop(session["id"], None)
|
||
|
||
|
||
async def test_post_external_mcp_startup_rejects_malformed_servers(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Malformed ``data.servers`` payloads are rejected with a 400.
|
||
|
||
A missing map, or a server record with an unknown status, must fail
|
||
loud at the route boundary rather than publish a bogus SSE frame the
|
||
web UI would render as a stuck startup band.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
missing = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "external_mcp_startup", "data": {}},
|
||
)
|
||
assert missing.status_code == 400, missing.text
|
||
assert "external_mcp_startup" in missing.text
|
||
|
||
bad_status = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_mcp_startup",
|
||
"data": {"servers": {"safe": {"status": "exploded"}}},
|
||
},
|
||
)
|
||
assert bad_status.status_code == 400, bad_status.text
|
||
assert "external_mcp_startup" in bad_status.text
|
||
|
||
|
||
async def test_post_external_conversation_item_auto_assigns_response_id(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
Mirrored items get a server-generated response id when none is sent.
|
||
|
||
The claude-native forwarder normally carries Claude's transcript
|
||
response id forward, but the route contract documents that
|
||
``response_id`` is optional: an external POST that omits it must
|
||
still produce a groupable item with a stable server-assigned id.
|
||
Failure here means the route silently rejects valid forwarder
|
||
output or accepts items that can't be grouped under a response.
|
||
"""
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture session-stream events emitted by the route.
|
||
|
||
:param session_id: Session id passed to ``session_stream``.
|
||
:param event: Event payload published to the stream.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
capture_publish,
|
||
)
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
# response_id omitted to exercise the auto-assign branch.
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "auto-id me"}],
|
||
},
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
items = snap.json()["items"]
|
||
assert len(items) == 1
|
||
persisted_response_id = items[0]["response_id"]
|
||
assert isinstance(persisted_response_id, str) and persisted_response_id, (
|
||
"auto-assigned response_id must be a non-empty string"
|
||
)
|
||
assert published[0][1]["type"] == "session.input.consumed"
|
||
|
||
|
||
async def test_external_user_message_seeds_title_on_claude_native_session(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
First forwarded user message seeds the title on a claude-native session.
|
||
|
||
With the placeholder carve-out removed, ``omnigent claude``
|
||
creates sessions without a title — same shape as every other
|
||
untitled session. The transcript forwarder's first
|
||
``external_conversation_item`` user-message POST must trigger
|
||
the generic ``_seed_missing_title_from_user_message`` path and
|
||
populate the title with the standard first-60-char synthesis.
|
||
This pins the integration of that helper with the external-
|
||
conversation-item route for the claude-native label combination,
|
||
so a future refactor of either side can't silently break the
|
||
sidebar's first-message title for these sessions.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
# No title — claude-native wrapper no longer stamps one.
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
# Precondition: the session was created with no title. If this fails,
|
||
# the create-time placeholder regressed in the wrapper.
|
||
assert session["title"] is None, (
|
||
f"precondition: claude-native session should be created without a "
|
||
f"title now that the placeholder was removed; got {session['title']!r}"
|
||
)
|
||
|
||
first_message = "investigate this stack trace from yesterday"
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "external_conversation_item",
|
||
"data": {
|
||
"item_type": "message",
|
||
"source_id": "src_first_user_msg",
|
||
"item_data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": first_message}],
|
||
},
|
||
},
|
||
},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
|
||
snap = await client.get(f"/v1/sessions/{session['id']}")
|
||
assert snap.status_code == 200
|
||
# The synthesized title equals the first message when it already fits
|
||
# the first-60-char budget. If this is empty or unchanged from None,
|
||
# the generic seed path didn't fire on the external_conversation_item
|
||
# route for claude-native labels.
|
||
assert snap.json()["title"] == first_message, (
|
||
f"first user message did not seed the title; title={snap.json()['title']!r}"
|
||
)
|
||
|
||
|
||
async def test_interrupt_on_claude_native_session_skips_idle_publish_on_runner_failure(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
If the runner couldn't deliver the Escape (e.g. tmux pane gone),
|
||
Omnigent must NOT lie to the UI by publishing idle. The spinner spins
|
||
is the right signal — it tells the user the cancel didn't land.
|
||
|
||
After the interrupt-unification refactor the Omnigent side no longer
|
||
publishes ``session.status: idle`` itself at all. Idle on a
|
||
claude-native interrupt now comes from the runner's PTY activity
|
||
watcher once the pane quiesces after the Escape (a failed Escape
|
||
naturally surfaces as "no idle" — the pane keeps changing). This
|
||
test acts as a regression guard against re-adding an AP-side idle
|
||
publish — if someone reintroduces the pre-refactor "publish idle on
|
||
2xx" logic, the 503 path here would start leaking idle.
|
||
"""
|
||
from omnigent.runtime import session_stream, set_runner_client
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Return 503 — the bridge-not-ready shape from the runner."""
|
||
del request
|
||
return httpx.Response(503, json={"error": "claude_native_interrupt_failed"})
|
||
|
||
published: list[dict[str, Any]] = []
|
||
real_publish = session_stream.publish
|
||
|
||
def _capture_publish(conversation_id: str, event: dict[str, Any]) -> None:
|
||
"""Intercept every event the route publishes."""
|
||
published.append({"conversation_id": conversation_id, **event})
|
||
real_publish(conversation_id, event)
|
||
|
||
monkeypatch.setattr(session_stream, "publish", _capture_publish)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "interrupt", "data": {}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# interrupted still fires (the UI marks the bubble cancelled);
|
||
# idle does not (the Escape didn't land).
|
||
interrupted = [e for e in published if e.get("type") == "session.interrupted"]
|
||
assert interrupted, (
|
||
f"session.interrupted should still publish on runner failure; got {published!r}"
|
||
)
|
||
idle_status = [
|
||
e for e in published if e.get("type") == "session.status" and e.get("status") == "idle"
|
||
]
|
||
assert not idle_status, (
|
||
f"AP must NOT publish session.status: idle when the runner "
|
||
f"rejected the interrupt — the spinner staying is the correct "
|
||
f"signal that the cancel didn't land. Got: {idle_status!r}"
|
||
)
|
||
|
||
|
||
async def test_stop_session_forwards_stop_session_event_to_runner(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
POST ``/events`` ``stop_session`` forwards the event verbatim to
|
||
the bound runner's ``/events`` endpoint.
|
||
|
||
The Omnigent server stays harness-agnostic: it doesn't kill anything
|
||
itself, it relays a ``{"type": "stop_session"}`` event to the
|
||
runner, whose dispatch decides what to do (hard-kill tmux for
|
||
claude-native, 204 for in-process). This pins that the Omnigent forward
|
||
fires and addresses the runner's per-session ``/events`` path with
|
||
the right body — a regression that dropped the forward, mangled
|
||
the body, or hit the wrong URL would silently make the web UI's
|
||
"Stop session" button a no-op.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
forwarded: list[_ForwardedEffort] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record POSTs to the runner; let snapshot reads pass through."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
body = json.loads(request.content) if request.content else None
|
||
forwarded.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "stop_session", "data": {}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# Control event, not a persisted item.
|
||
assert resp.json() == {"queued": False}, (
|
||
f"stop_session is a control event and must return "
|
||
f"{{'queued': False}}; got {resp.json()!r}"
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# Exactly one POST to the session's /events path, carrying the
|
||
# stop_session type. 0 = the Omnigent branch didn't forward (no-op stop
|
||
# button); 2+ = a duplicate relay. Snapshot GETs are filtered out
|
||
# by the handler, so this isolates the control-event forward.
|
||
events_forwards = [
|
||
f for f in forwarded if f.url == f"http://runner/v1/sessions/{session['id']}/events"
|
||
]
|
||
assert len(events_forwards) == 1, (
|
||
f"Expected exactly one POST of stop_session to the session's "
|
||
f"/events path, got {len(events_forwards)} within {forwarded!r}"
|
||
)
|
||
assert events_forwards[0].body == {"type": "stop_session"}, (
|
||
f"AP must forward a bare stop_session control event to the "
|
||
f"runner; got {events_forwards[0].body!r}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"failure_mode",
|
||
[
|
||
# Runner reachable but it couldn't kill the session (503 leg).
|
||
"runner_503",
|
||
# WS tunnel closed mid-POST: a BARE ConnectionError, not an httpx.HTTPError.
|
||
"bare_connection_error",
|
||
],
|
||
)
|
||
async def test_stop_session_surfaces_runner_failure_as_error(
|
||
client: httpx.AsyncClient,
|
||
failure_mode: str,
|
||
) -> None:
|
||
"""
|
||
A runner that can't kill the session propagates to the client as
|
||
an error, not a fake success.
|
||
|
||
Unlike effort/model_change (where a dropped forward is benign), a
|
||
failed ``stop_session`` means the session is still alive. If the
|
||
Omnigent server swallowed the runner's 503 and returned 202
|
||
``{queued: false}``, the web UI would close its confirmation
|
||
dialog as if the session stopped — the exact silent-failure the
|
||
review flagged. This pins that the Omnigent route raises (non-2xx)
|
||
instead, so the frontend mutation lands in its error state and
|
||
can tell the user the stop didn't land. The bare-ConnectionError
|
||
leg pins the WS-tunnel transport error mapping to the same clean
|
||
RUNNER_UNAVAILABLE 503 rather than leaking a raw 500.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Snapshot GETs pass; the stop POST gets the runner failure."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
if failure_mode == "bare_connection_error":
|
||
# What WSTunnelTransport raises when the tunnel closes mid-request.
|
||
raise ConnectionError("tunnel closed mid-request")
|
||
# Shape the runner's _handle_claude_native_stop 503 emits when
|
||
# kill_session can't reach the tmux pane.
|
||
return httpx.Response(503, json={"error": "claude_native_stop_failed"})
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={"type": "stop_session", "data": {}},
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# RUNNER_UNAVAILABLE → 503. A 202 here would mean the failure was
|
||
# swallowed and the UI would falsely report success.
|
||
assert resp.status_code == 503, (
|
||
f"A runner 503 on stop_session must surface as a non-2xx to the "
|
||
f"client (RUNNER_UNAVAILABLE → 503), got {resp.status_code}: {resp.text}"
|
||
)
|
||
# The failed stop also lifts the just-installed turn fence: the turn
|
||
# keeps running and nothing else would ever lift it, so leaving it set
|
||
# would silently drop the rest of the turn (live + durable).
|
||
from omnigent.server.routes.sessions import _interrupt_fenced_sessions
|
||
|
||
assert session["id"] not in _interrupt_fenced_sessions, (
|
||
"a failed stop_session must remove the interrupt fence it installed"
|
||
)
|
||
|
||
|
||
async def test_stop_session_no_runner_lifts_stop_fence(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A stop with no runner bound anywhere still removes the turn fence.
|
||
|
||
When neither the session router nor the global fallback resolves a
|
||
runner client, ``_stop_session_via_runner`` treats the stop as a
|
||
no-op success (the session is not running on any runner) and does
|
||
not raise. The fence installed just before the forward must not
|
||
outlive that no-op: nothing else would ever lift it, and the
|
||
interrupt branch already unfences in the same no-client situation.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
from omnigent.server.routes.sessions import _interrupt_fenced_sessions
|
||
|
||
# Pin the no-runner precondition: no global fallback client either.
|
||
set_runner_client(None)
|
||
session_id: str | None = None
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session_id}/events",
|
||
json={"type": "stop_session", "data": {}},
|
||
)
|
||
# No runner resolved = no-op success, not a RUNNER_UNAVAILABLE 503.
|
||
assert resp.status_code == 202, resp.text
|
||
assert session_id not in _interrupt_fenced_sessions, (
|
||
"a no-runner stop_session must remove the fence it installed — "
|
||
"nothing else would ever lift it"
|
||
)
|
||
finally:
|
||
if session_id is not None:
|
||
_interrupt_fenced_sessions.discard(session_id)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"failure_mode",
|
||
[
|
||
# Runner unreachable: the 5s POST raises (the swallowed-HTTPError leg).
|
||
"transport_error",
|
||
# WS tunnel closed mid-POST: a BARE ConnectionError, not an httpx.HTTPError.
|
||
"bare_connection_error",
|
||
# Runner reachable but the cancel didn't land (claude-native 503 leg).
|
||
"runner_503",
|
||
# No runner client resolves at all: nothing was forwarded anywhere.
|
||
"no_runner_client",
|
||
],
|
||
)
|
||
async def test_interrupt_forward_failure_lifts_stop_fence(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
failure_mode: str,
|
||
) -> None:
|
||
"""
|
||
A failed interrupt forward removes the fence it just installed.
|
||
|
||
The fence is installed BEFORE forwarding the interrupt to the runner.
|
||
If the forward fails the turn keeps running — but the fence used to
|
||
stay set, so the relay dropped the WHOLE remainder of the turn (text,
|
||
items, completed) both live and durably, with nothing left to lift it
|
||
until the next turn. The route must lift the fence when the interrupt
|
||
demonstrably did not land.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
from omnigent.server.routes.sessions import _interrupt_fenced_sessions
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Fail the interrupt POST; let everything else pass through."""
|
||
if request.method == "POST" and request.url.path.endswith("/events"):
|
||
if failure_mode == "transport_error":
|
||
raise httpx.ConnectError("runner unreachable")
|
||
if failure_mode == "bare_connection_error":
|
||
# What WSTunnelTransport raises when the tunnel closes mid-request.
|
||
raise ConnectionError("tunnel closed mid-request")
|
||
return httpx.Response(503, json={"error": "claude_native_interrupt_failed"})
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient | None:
|
||
"""Resolve every session to the failing fake runner (or to none)."""
|
||
del session_id, runner_router
|
||
return None if failure_mode == "no_runner_client" else fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
session_id: str | None = None
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session_id}/events",
|
||
json={"type": "interrupt", "data": {}},
|
||
)
|
||
# Interrupt is best-effort and still ACKs (the UI already marked the
|
||
# bubble interrupted); the fence removal below is the fix under test.
|
||
assert resp.status_code == 202, resp.text
|
||
assert session_id not in _interrupt_fenced_sessions, (
|
||
"a failed interrupt forward must remove the fence it installed — "
|
||
"leaving it set drops the rest of the still-running turn"
|
||
)
|
||
finally:
|
||
if session_id is not None:
|
||
_interrupt_fenced_sessions.discard(session_id)
|
||
await fake_runner.aclose()
|
||
|
||
|
||
async def test_interrupt_forward_success_keeps_stop_fence(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
A delivered interrupt keeps the fence so trailing output stays dropped.
|
||
|
||
Counterpart of the forward-failure test: it proves the failure-path
|
||
unfence is failure-scoped. If this fence were missing, the cancelled
|
||
turn's trailing deltas would leak into the transcript and live stream
|
||
(the original stop-mid-stream bug).
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_module
|
||
from omnigent.server.routes.sessions import _interrupt_fenced_sessions
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Accept the interrupt POST (2xx) and all other requests."""
|
||
del request
|
||
return httpx.Response(202)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
async def _fake_get_runner_client(
|
||
session_id: str,
|
||
runner_router: object,
|
||
) -> httpx.AsyncClient:
|
||
"""Resolve every session to the accepting fake runner."""
|
||
del session_id, runner_router
|
||
return fake_runner
|
||
|
||
monkeypatch.setattr(sessions_module, "_get_runner_client", _fake_get_runner_client)
|
||
session_id: str | None = None
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
session_id = session["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session_id}/events",
|
||
json={"type": "interrupt", "data": {}},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
# 2xx from the runner = the cancel landed; the fence must stay so
|
||
# the dying turn's trailing response.* events are suppressed.
|
||
assert session_id in _interrupt_fenced_sessions, (
|
||
"a delivered interrupt must keep the fence installed"
|
||
)
|
||
finally:
|
||
if session_id is not None:
|
||
_interrupt_fenced_sessions.discard(session_id)
|
||
await fake_runner.aclose()
|
||
|
||
|
||
@dataclass
|
||
class _ForwardedEffort:
|
||
"""
|
||
One forward of an effort change to the runner.
|
||
|
||
:param url: Fully-qualified runner URL the Omnigent server POSTed to.
|
||
:param body: Parsed JSON body the Omnigent server sent, or ``None``
|
||
when the request had no body.
|
||
"""
|
||
|
||
url: str
|
||
body: dict[str, Any] | None
|
||
|
||
|
||
async def test_patch_collaboration_mode_persists_label_and_forwards_event(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
PATCH ``collaboration_mode`` persists the Codex mode and forwards it live.
|
||
|
||
The web UI toggle writes through the sessions PATCH route. The server must
|
||
persist the collaboration-mode label for reload, publish a live
|
||
``session.collaboration_mode`` event for connected clients, and forward a
|
||
harness-agnostic ``plan_mode_change`` control event to the runner so the
|
||
loaded Codex app-server switches modes immediately.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
captured: list[_ForwardedEffort] = []
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record POSTs to /events; let snapshot/status reads pass through."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
body: dict[str, Any] | None = None
|
||
if request.content:
|
||
body = json.loads(request.content)
|
||
captured.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "codex-native-ui",
|
||
},
|
||
)
|
||
captured.clear()
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"collaboration_mode": "plan"},
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["labels"]["omnigent.codex_native.collaboration_mode"] == "plan"
|
||
plan_forwards = [f for f in captured if f.url.endswith(f"/v1/sessions/{session['id']}/events")]
|
||
assert len(plan_forwards) == 1, f"Expected one runner forward, got {captured!r}"
|
||
assert plan_forwards[0].body == {"type": "plan_mode_change", "enabled": True}
|
||
assert [event["type"] for _, event in published] == ["session.collaboration_mode"]
|
||
assert published[0][1]["mode"] == "plan"
|
||
|
||
|
||
@pytest.mark.parametrize("runner_status", [None, 503], ids=["no_runner", "runner_rejects"])
|
||
async def test_patch_collaboration_mode_requires_live_runner_before_persisting(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
runner_status: int | None,
|
||
) -> None:
|
||
"""
|
||
PATCH ``collaboration_mode`` must not persist UI state before live success.
|
||
|
||
A Plan-mode toggle is only correct if Codex app-server accepts the
|
||
corresponding ``thread/settings/update`` through the runner. If no runner
|
||
is reachable, or the runner reports that the loaded Codex bridge cannot
|
||
apply the update, the route must fail and leave the collaboration label
|
||
absent so the web UI rolls back instead of showing a false Plan indicator.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
captured: list[_ForwardedEffort] = []
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
monkeypatch.setattr(
|
||
"omnigent.server.routes.sessions.session_stream.publish",
|
||
lambda sid, ev: published.append((sid, ev)),
|
||
)
|
||
|
||
fake_runner: httpx.AsyncClient | None = None
|
||
if runner_status is not None:
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""
|
||
Record the plan-mode forward and reject it like a missing bridge.
|
||
|
||
:param request: Runner request received by the mock transport.
|
||
:returns: Runner response with ``runner_status``.
|
||
"""
|
||
body: dict[str, Any] | None = None
|
||
if request.content:
|
||
body = json.loads(request.content)
|
||
captured.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(
|
||
runner_status,
|
||
json={
|
||
"error": "codex_native_settings_update_failed",
|
||
"detail": "Codex-native plan-mode update requires a loaded Codex bridge.",
|
||
},
|
||
)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "codex-native-ui",
|
||
},
|
||
)
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"collaboration_mode": "plan"},
|
||
)
|
||
snapshot = (await client.get(f"/v1/sessions/{session['id']}")).json()
|
||
finally:
|
||
if fake_runner is not None:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
assert resp.status_code == 503, resp.text
|
||
assert "Could not enter Plan mode" in resp.text
|
||
assert "omnigent.codex_native.collaboration_mode" not in snapshot["labels"]
|
||
assert published == []
|
||
if runner_status is None:
|
||
assert captured == []
|
||
else:
|
||
plan_forwards = [
|
||
f for f in captured if f.url.endswith(f"/v1/sessions/{session['id']}/events")
|
||
]
|
||
assert len(plan_forwards) == 1, f"Expected one rejected forward, got {captured!r}"
|
||
assert plan_forwards[0].body == {"type": "plan_mode_change", "enabled": True}
|
||
|
||
|
||
async def test_patch_collaboration_mode_rejects_non_codex_session(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``collaboration_mode`` is rejected for sessions that are not Codex-native.
|
||
|
||
This keeps a Codex-specific UI control from becoming a generic label write
|
||
that could imply Plan mode on sessions whose runner cannot honor it.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"collaboration_mode": "plan"},
|
||
)
|
||
|
||
assert resp.status_code == 400, resp.text
|
||
assert "collaboration_mode is only supported" in resp.text
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"native_session,patch_effort,expected_persisted,expected_event_effort",
|
||
[
|
||
# (1) Native + claude-accepted level → POSTs effort_change.
|
||
# The motivating case: dropdown click on a running pane.
|
||
(True, "high", "high", "high"),
|
||
# (2) Non-native + same level → Omnigent server is harness-agnostic
|
||
# so it ALSO POSTs effort_change. The runner's /events
|
||
# dispatch will 204 no-op (covered by a runner-side test);
|
||
# AP's job is just to forward.
|
||
(False, "high", "high", "high"),
|
||
# (3) Clear on native session → persisted None gets forwarded
|
||
# as effort=None. The runner-side native handler decides to
|
||
# skip injection (Claude has no slash for "use spawn default").
|
||
# Before refactor, Omnigent would short-circuit and not POST at all.
|
||
(True, "default", None, None),
|
||
# (4) Level in EFFORT_VALUES but not CLAUDE_EFFORTS (``none``,
|
||
# ``minimal``). After refactor Omnigent no longer filters — it
|
||
# forwards as-is, and the runner-side handler skips injection.
|
||
(True, "none", "none", "none"),
|
||
],
|
||
)
|
||
async def test_patch_reasoning_effort_forwards_effort_change_event(
|
||
client: httpx.AsyncClient,
|
||
native_session: bool,
|
||
patch_effort: str,
|
||
expected_persisted: str | None,
|
||
expected_event_effort: str | None,
|
||
) -> None:
|
||
"""
|
||
PATCH effort always forwards an ``effort_change`` event to
|
||
runner ``/events`` — harness-agnostic on the Omnigent side.
|
||
|
||
Before the refactor, Omnigent server made a native-only POST to
|
||
``/claude-native-effort`` and filtered out clear / unsupported
|
||
values. After the refactor:
|
||
|
||
* The POST target is the same generic ``/events`` route every
|
||
other harness event uses.
|
||
* The body is the new ``effort_change`` discriminator with the
|
||
persisted level (or ``None`` for clear) — runner-side dispatch
|
||
decides what to do with it.
|
||
* Omnigent server does not check ``_is_native_terminal_session``
|
||
and does not filter on level — every PATCH that changes effort
|
||
sends the event.
|
||
|
||
This parametrize subsumes four pre-refactor tests (the native
|
||
forward, non-native skip, clear skip, unsupported skip): each
|
||
case here was a separate test before, and each had its
|
||
"doesn't POST" / "POSTs to claude-native-effort" assertion
|
||
flipped to "POSTs effort_change to /events".
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
captured: list[_ForwardedEffort] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record POSTs to /events; let snapshot/status reads pass through."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
body: dict[str, Any] | None = None
|
||
if request.content:
|
||
try:
|
||
body = json.loads(request.content)
|
||
except json.JSONDecodeError:
|
||
body = None
|
||
captured.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session_kwargs = (
|
||
{
|
||
"labels": {
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
}
|
||
if native_session
|
||
else {}
|
||
)
|
||
session = await _create_session(client, agent["id"], **session_kwargs)
|
||
# If we're testing clear, seed an explicit level first so the
|
||
# PATCH actually transitions something (not a no-op).
|
||
if patch_effort == "default":
|
||
seed = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "high"},
|
||
)
|
||
assert seed.status_code == 200
|
||
captured.clear()
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": patch_effort},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
# PATCH response reflects the persisted level regardless of
|
||
# forward status (the forward is best-effort).
|
||
assert resp.json()["reasoning_effort"] == expected_persisted, (
|
||
f"PATCH should persist {expected_persisted!r}, got {resp.json()!r}"
|
||
)
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# Exactly one POST to the unified /events route. 0 = Omnigent server
|
||
# silently dropped the forward (regression in the harness-agnostic
|
||
# always-forward path); 2+ = a legacy branch (e.g. the deleted
|
||
# _forward_claude_native_effort helper) snuck back in alongside
|
||
# the unified path.
|
||
events_forwards = [
|
||
f for f in captured if f.url.endswith(f"/v1/sessions/{session['id']}/events")
|
||
]
|
||
assert len(events_forwards) == 1, (
|
||
f"Expected exactly one POST to /events, got {len(events_forwards)} "
|
||
f"within all runner requests: {captured!r}"
|
||
)
|
||
forward = events_forwards[0]
|
||
# Body contract: runner reads ``type`` and ``effort`` from this
|
||
# to drive the harness-specific dispatch. A regression in shape
|
||
# (legacy ``{effort: ...}`` without ``type``) would either 400
|
||
# at the runner's discriminator or land as a no-op event.
|
||
assert forward.body == {
|
||
"type": "effort_change",
|
||
"effort": expected_event_effort,
|
||
}, (
|
||
f"Expected body {{'type': 'effort_change', 'effort': "
|
||
f"{expected_event_effort!r}}}, got {forward.body!r}."
|
||
)
|
||
|
||
# No POST to the legacy ``/claude-native-effort`` route should
|
||
# happen anymore — its callsite is gone from Omnigent server. A non-
|
||
# empty list here means the deleted ``_forward_claude_native_effort``
|
||
# helper (or an equivalent native-only branch) was re-introduced.
|
||
legacy_forwards = [f for f in captured if "/claude-native-effort" in f.url]
|
||
assert legacy_forwards == [], (
|
||
f"Legacy /claude-native-effort POSTs must not happen anymore; "
|
||
f"AP server forwards effort changes through /events. Got: "
|
||
f"{legacy_forwards!r}"
|
||
)
|
||
|
||
|
||
async def test_silent_patch_skips_effort_change_forward(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``silent: true`` persists effort but skips the ``/events`` forward.
|
||
|
||
Mirrors the silent-skip semantic the model_override path uses (see
|
||
``test_silent_patch_skips_claude_native_forward``): bind-time
|
||
sticky-pref handoff on a fresh session must not inject a visible
|
||
``/effort X`` slash-command item into the pane before the user
|
||
has sent anything. The persisted value is still authoritative —
|
||
the next spawn picks it up via ``--effort``.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
captured: list[_ForwardedEffort] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record POSTs to /events; let snapshot/status reads pass through."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
body: dict[str, Any] | None = None
|
||
if request.content:
|
||
try:
|
||
body = json.loads(request.content)
|
||
except json.JSONDecodeError:
|
||
body = None
|
||
captured.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(204)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
captured.clear()
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "high", "silent": True},
|
||
)
|
||
# 200 with persisted value — silent only suppresses the live
|
||
# forward, not the store update.
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["reasoning_effort"] == "high"
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# No effort_change forward must reach the runner. A non-empty
|
||
# list here means the silent flag was ignored and bind-time
|
||
# sticky-pref handoff would inject a visible ``/effort high``
|
||
# item into a fresh pane — the bug this skip exists to prevent.
|
||
effort_forwards = [
|
||
f
|
||
for f in captured
|
||
if f.url.endswith(f"/v1/sessions/{session['id']}/events")
|
||
and isinstance(f.body, dict)
|
||
and f.body.get("type") == "effort_change"
|
||
]
|
||
assert effort_forwards == [], (
|
||
f"silent=True must skip the effort_change forward; got {effort_forwards!r}. "
|
||
f"All runner POSTs: {captured!r}."
|
||
)
|
||
|
||
|
||
async def test_patch_reasoning_effort_swallows_runner_failure(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Runner 5xx on the effort_change forward does not break PATCH.
|
||
|
||
The forward is best-effort: a missing/unresponsive tmux pane
|
||
leaves the persisted value as the authoritative record, and
|
||
the next spawn picks it up via ``--effort``. PATCH must still
|
||
return 200 with the new persisted value.
|
||
|
||
Updated for the unified-events refactor: the URL the runner
|
||
rejects is now ``/events`` (not the deleted
|
||
``/claude-native-effort``), but the swallow-and-return-200
|
||
contract on the Omnigent side is unchanged.
|
||
"""
|
||
from omnigent.runtime import set_runner_client
|
||
|
||
captured: list[_ForwardedEffort] = []
|
||
|
||
def _handler(request: httpx.Request) -> httpx.Response:
|
||
"""Record POST + 503 — the runner-route's pane-not-ready shape."""
|
||
if request.method != "POST":
|
||
return httpx.Response(204)
|
||
body: dict[str, Any] | None = None
|
||
if request.content:
|
||
try:
|
||
body = json.loads(request.content)
|
||
except json.JSONDecodeError:
|
||
body = None
|
||
captured.append(_ForwardedEffort(url=str(request.url), body=body))
|
||
return httpx.Response(
|
||
503,
|
||
json={
|
||
"error": "claude_native_effort_failed",
|
||
"detail": "tmux target is not advertised",
|
||
},
|
||
)
|
||
|
||
fake_runner = httpx.AsyncClient(
|
||
transport=httpx.MockTransport(_handler),
|
||
base_url="http://runner",
|
||
)
|
||
set_runner_client(fake_runner)
|
||
try:
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={
|
||
"omnigent.ui": "terminal",
|
||
"omnigent.wrapper": "claude-code-native-ui",
|
||
},
|
||
)
|
||
|
||
resp = await client.patch(
|
||
f"/v1/sessions/{session['id']}",
|
||
json={"reasoning_effort": "high"},
|
||
)
|
||
# 200 even though the runner 503'd — clicking the dropdown
|
||
# should never appear to fail because the pane is detached.
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["reasoning_effort"] == "high"
|
||
finally:
|
||
await fake_runner.aclose()
|
||
set_runner_client(None)
|
||
|
||
# One effort_change forward was attempted (proves we got far
|
||
# enough to talk to the runner — i.e. the failure was swallowed,
|
||
# not skipped). 0 = Omnigent server bailed before forwarding (regression
|
||
# in the always-forward contract).
|
||
events_forwards = [
|
||
f
|
||
for f in captured
|
||
if f.url.endswith(f"/v1/sessions/{session['id']}/events")
|
||
and isinstance(f.body, dict)
|
||
and f.body.get("type") == "effort_change"
|
||
]
|
||
assert len(events_forwards) == 1, (
|
||
f"Expected one effort_change POST attempt before swallow, got "
|
||
f"{len(events_forwards)} within all runner requests: {captured!r}"
|
||
)
|
||
|
||
|
||
# ── POST /v1/sessions/{id}/events with client-side tools ────
|
||
|
||
|
||
async def test_session_event_tools_malformed_returns_400(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""A malformed ``tools`` entry fails fast at the route boundary."""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"])
|
||
|
||
# Missing function.name is the canonical malformed case.
|
||
resp = await client.post(
|
||
f"/v1/sessions/{session['id']}/events",
|
||
json={
|
||
"type": "message",
|
||
"data": {
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": "hi"}],
|
||
},
|
||
"tools": [{"type": "function", "function": {"description": "no name"}}],
|
||
},
|
||
)
|
||
assert resp.status_code == 400, (
|
||
f"Expected 400 for malformed tools; got {resp.status_code}: {resp.text[:200]}"
|
||
)
|
||
|
||
|
||
# ── POST /v1/sessions/{id}/events external_codex_subagent_start ──────────────
|
||
|
||
|
||
async def test_external_codex_subagent_start_mints_child_session(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``external_codex_subagent_start`` creates a child session with the
|
||
expected labels and returns the child session id.
|
||
|
||
The Codex-native forwarder posts this event when it discovers a
|
||
``collabAgentToolCall`` child thread. The child must surface in the
|
||
``child_sessions`` listing with the Codex nickname as the ``tool``
|
||
label.
|
||
|
||
:param client: The test HTTP client.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "codex-native-ui"},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {
|
||
"thread_id": "thread_child_alpha",
|
||
"parent_thread_id": "thread_parent",
|
||
"tool_call_id": "collab_123",
|
||
"prompt": "Audit the auth flow",
|
||
"agent_nickname": "auth-auditor",
|
||
"agent_role": "reviewer",
|
||
},
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 202, f"unexpected status {resp.status_code}: {resp.text}"
|
||
child_id = resp.json()["child_session_id"]
|
||
assert child_id.startswith("conv_"), (
|
||
f"child_session_id must start with conv_; got {child_id!r}"
|
||
)
|
||
|
||
children_resp = await client.get(f"/v1/sessions/{parent['id']}/child_sessions")
|
||
assert children_resp.status_code == 200
|
||
children = children_resp.json()["data"]
|
||
child = next((c for c in children if c["id"] == child_id), None)
|
||
assert child is not None, "Child session must appear in child_sessions listing"
|
||
|
||
# tool is derived from agent_nickname → agent_role → "Codex".
|
||
assert child["tool"] == "auth-auditor", (
|
||
f"Expected tool='auth-auditor' (from agent_nickname); got {child['tool']!r}"
|
||
)
|
||
assert child["session_name"] == "thread_child_alpha", (
|
||
f"Expected session_name=thread_id; got {child['session_name']!r}"
|
||
)
|
||
labels = child["labels"]
|
||
assert labels["omnigent.codex_native.subagent_thread_id"] == "thread_child_alpha"
|
||
assert labels["omnigent.codex_native.parent_thread_id"] == "thread_parent"
|
||
assert labels["omnigent.codex_native.collab_tool_call_id"] == "collab_123"
|
||
assert labels["omnigent.codex_native.agent_nickname"] == "auth-auditor"
|
||
assert labels["omnigent.codex_native.agent_role"] == "reviewer"
|
||
# Prompt must surface as the preview before real transcript arrives.
|
||
assert child["last_message_preview"] == "Audit the auth flow", (
|
||
f"Expected prompt preview before transcript exists; got {child['last_message_preview']!r}"
|
||
)
|
||
|
||
|
||
async def test_external_codex_subagent_start_is_idempotent_and_upserts_labels(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Re-registering the same Codex child thread returns the existing child
|
||
and upserts richer metadata without minting a duplicate row.
|
||
|
||
Codex may surface a child first via ``thread/started`` (sparse) and
|
||
later via the parent ``collabAgentToolCall`` (richer). The second POST
|
||
must update the labels without creating a second child row.
|
||
|
||
:param client: The test HTTP client.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "codex-native-ui"},
|
||
)
|
||
|
||
# First registration — sparse (no nickname yet).
|
||
first = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {
|
||
"thread_id": "thread_child_beta",
|
||
"parent_thread_id": "thread_parent",
|
||
},
|
||
},
|
||
)
|
||
assert first.status_code == 202, first.text
|
||
child_id_first = first.json()["child_session_id"]
|
||
|
||
# Second registration — richer (nickname/role added from resume).
|
||
second = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {
|
||
"thread_id": "thread_child_beta",
|
||
"parent_thread_id": "thread_parent",
|
||
"agent_nickname": "Euclid",
|
||
"agent_role": "explorer",
|
||
},
|
||
},
|
||
)
|
||
assert second.status_code == 202, second.text
|
||
child_id_second = second.json()["child_session_id"]
|
||
|
||
# Must return the same child id — no duplicate row minted.
|
||
assert child_id_first == child_id_second, (
|
||
f"Idempotent re-registration must return the same child id; "
|
||
f"got {child_id_first!r} then {child_id_second!r}"
|
||
)
|
||
|
||
children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"]
|
||
matching = [c for c in children if c["session_name"] == "thread_child_beta"]
|
||
# Exactly one row: no duplicate was minted.
|
||
assert len(matching) == 1, (
|
||
f"Expected exactly 1 child row for thread_child_beta; got {len(matching)}"
|
||
)
|
||
# Upserted nickname must be present.
|
||
assert matching[0]["tool"] == "Euclid", (
|
||
f"Expected tool='Euclid' after nickname upsert; got {matching[0]['tool']!r}"
|
||
)
|
||
|
||
|
||
async def test_external_codex_subagent_start_adopts_unlabeled_title_collision(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
Codex re-registration adopts an existing child row that carries the
|
||
colliding title but no thread-id label.
|
||
|
||
Same wedge as the claude-native variant: a registration that died
|
||
between ``create_conversation`` and ``set_labels`` leaves a row the
|
||
label-based recovery lookup can't see, so without the title
|
||
fallback every redelivery re-trips the ``(parent, title)`` unique
|
||
index and 500s forever.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "codex-native-ui"},
|
||
)
|
||
|
||
# Seed the wedge: the exact collision title, no codex labels.
|
||
seeded = await client.post(
|
||
"/v1/sessions",
|
||
json={
|
||
"agent_id": agent["id"],
|
||
"parent_session_id": parent["id"],
|
||
"title": "codex-native-ui-subagent:thread_child_gamma",
|
||
},
|
||
)
|
||
assert seeded.status_code == 201, seeded.text
|
||
seeded_id = seeded.json()["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {
|
||
"thread_id": "thread_child_gamma",
|
||
"parent_thread_id": "thread_parent",
|
||
},
|
||
},
|
||
)
|
||
# 202, NOT 500 — the collision must be adopted, not escape as an
|
||
# unhandled NameAlreadyExistsError.
|
||
assert resp.status_code == 202, f"unexpected status {resp.status_code}: {resp.text}"
|
||
assert resp.json()["child_session_id"] == seeded_id
|
||
|
||
children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"]
|
||
# Exactly one child: adoption must not mint a sibling row.
|
||
assert len(children) == 1
|
||
assert children[0]["id"] == seeded_id
|
||
# Thread-id label is healed so future deliveries resolve via the
|
||
# normal label lookup.
|
||
assert (
|
||
children[0]["labels"]["omnigent.codex_native.subagent_thread_id"] == "thread_child_gamma"
|
||
)
|
||
|
||
# Redelivery now takes the label-lookup path to the same id.
|
||
again = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {
|
||
"thread_id": "thread_child_gamma",
|
||
"parent_thread_id": "thread_parent",
|
||
},
|
||
},
|
||
)
|
||
assert again.json()["child_session_id"] == seeded_id
|
||
|
||
|
||
async def test_external_codex_subagent_start_rejects_missing_thread_id(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
``external_codex_subagent_start`` requires a non-empty ``thread_id``.
|
||
|
||
The forwarder always supplies the Codex child thread id. A missing or
|
||
empty value is a malformed request that must be rejected with 400.
|
||
|
||
:param client: The test HTTP client.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "codex-native-ui"},
|
||
)
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={"type": "external_codex_subagent_start", "data": {}},
|
||
)
|
||
|
||
assert resp.status_code == 400, f"Expected 400; got {resp.status_code}: {resp.text}"
|
||
assert "thread_id" in resp.text, (
|
||
f"Expected error message to mention 'thread_id'; got: {resp.text[:300]}"
|
||
)
|
||
|
||
|
||
async def test_external_codex_subagent_terminal_status_accepted_without_runner(
|
||
client: httpx.AsyncClient,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""
|
||
``external_session_status`` on a Codex internal child does not require
|
||
runner delivery.
|
||
|
||
Omnigent-spawned native sub-agents must forward terminal status to the
|
||
parent runner inbox. Codex AgentControl children are tracked inside the
|
||
same app-server thread tree and have no runner inbox entry, so the
|
||
``_require_external_status_forward`` guard must be bypassed for them.
|
||
|
||
:param client: The test HTTP client.
|
||
:param monkeypatch: Pytest monkeypatch fixture.
|
||
"""
|
||
from omnigent.server.routes import sessions as sessions_mod
|
||
|
||
published: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def capture_publish(session_id: str, event: dict[str, Any]) -> None:
|
||
"""
|
||
Capture live stream events from the route.
|
||
|
||
:param session_id: Session being published to.
|
||
:param event: Stream event payload.
|
||
:returns: None.
|
||
"""
|
||
published.append((session_id, event))
|
||
|
||
monkeypatch.setattr(sessions_mod.session_stream, "publish", capture_publish)
|
||
|
||
agent = await create_test_agent(client)
|
||
parent = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "codex-native-ui"},
|
||
)
|
||
start_resp = await client.post(
|
||
f"/v1/sessions/{parent['id']}/events",
|
||
json={
|
||
"type": "external_codex_subagent_start",
|
||
"data": {"thread_id": "thread_child_status"},
|
||
},
|
||
)
|
||
assert start_resp.status_code == 202, start_resp.text
|
||
child_id = start_resp.json()["child_session_id"]
|
||
|
||
status_resp = await client.post(
|
||
f"/v1/sessions/{child_id}/events",
|
||
json={"type": "external_session_status", "data": {"status": "idle"}},
|
||
)
|
||
|
||
# Must succeed — no runner delivery is required for Codex-internal children.
|
||
assert status_resp.status_code == 202, (
|
||
f"Expected 202 for Codex child terminal status; "
|
||
f"got {status_resp.status_code}: {status_resp.text}"
|
||
)
|
||
# The status update must be published to the child session stream.
|
||
assert any(
|
||
sid == child_id and event.get("type") == "session.status" and event.get("status") == "idle"
|
||
for sid, event in published
|
||
), (
|
||
f"Expected a session.status=idle event for {child_id} in the stream; "
|
||
f"got {[(s, e.get('type')) for s, e in published]}"
|
||
)
|
||
|
||
|
||
async def test_native_message_persisted_when_runner_offline(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A native message is persisted (not dropped) when no runner is reachable.
|
||
|
||
Repro of the desktop bug: a native terminal session whose runner
|
||
crashed before connecting. Posting a message used to raise
|
||
RUNNER_UNAVAILABLE and silently drop the message — the user's input
|
||
vanished on reload while the session stalled. The fix persists the
|
||
user message together with a runner-failure error so both survive,
|
||
and the turn settles ``failed`` instead of 5xx-ing.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
# Native terminal-first session, no host/runner bound → runner offline.
|
||
session = await _create_session(
|
||
client,
|
||
agent["id"],
|
||
labels={"omnigent.wrapper": "claude-code-native-ui"},
|
||
)
|
||
sid = session["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={
|
||
"type": "message",
|
||
"data": {"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
|
||
},
|
||
)
|
||
# Queued (not RUNNER_UNAVAILABLE) and the persisted user item is returned.
|
||
assert resp.status_code == 202, resp.text
|
||
body = resp.json()
|
||
assert body["queued"] is True
|
||
assert body.get("item_id"), f"expected a persisted item_id, got {body}"
|
||
|
||
items = (await client.get(f"/v1/sessions/{sid}/items")).json()["data"]
|
||
# The user's message is durable history — the whole point of the fix.
|
||
user_msgs = [
|
||
i
|
||
for i in items
|
||
if i["type"] == "message" and i.get("content", [{}])[0].get("text") == "hello"
|
||
]
|
||
assert len(user_msgs) == 1, f"user message not persisted: {items}"
|
||
# A sibling error item explains the failure on reload.
|
||
error_items = [i for i in items if i["type"] == "error"]
|
||
assert len(error_items) == 1, f"expected one error item, got {items}"
|
||
assert error_items[0]["code"] == "runner_failed_to_start"
|
||
|
||
# The turn settles failed (not 5xx); the durable error item above is
|
||
# what the web re-renders on reload via itemsToBlocks. (The separate
|
||
# last_task_error snapshot field is the exit-report durability path,
|
||
# which needs a bound runner_id — covered by the snapshot unit test.)
|
||
snap = (await client.get(f"/v1/sessions/{sid}")).json()
|
||
assert snap["status"] == "failed"
|
||
|
||
|
||
async def test_non_native_message_still_raises_when_runner_offline(
|
||
client: httpx.AsyncClient,
|
||
) -> None:
|
||
"""
|
||
A NON-native message with no runner still fails loud (not persisted).
|
||
|
||
The persist-on-offline path is scoped to native terminal sessions
|
||
where the AP server is the legitimate writer. A regular session's
|
||
message would replay to a relaunched runner, so persisting it now
|
||
would desync the store from harness state — it must keep raising.
|
||
"""
|
||
agent = await create_test_agent(client)
|
||
session = await _create_session(client, agent["id"]) # no native wrapper label
|
||
sid = session["id"]
|
||
|
||
resp = await client.post(
|
||
f"/v1/sessions/{sid}/events",
|
||
json={
|
||
"type": "message",
|
||
"data": {"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
|
||
},
|
||
)
|
||
# RUNNER_UNAVAILABLE surfaces as an error status, and nothing is persisted.
|
||
assert resp.status_code >= 400, resp.text
|
||
items = (await client.get(f"/v1/sessions/{sid}/items")).json()["data"]
|
||
assert [i for i in items if i["type"] == "error"] == []
|