Files
2026-07-13 13:12:00 +08:00

7396 lines
279 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"] == []