Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f55c52315 | |||
| 1bed90fd54 | |||
| b5500de2b7 | |||
| d2e5f86afd |
@@ -4508,20 +4508,19 @@ def _session_usage_data_from_params(params: dict[str, Any]) -> dict[str, int] |
|
||||
total = token_usage.get("total")
|
||||
if not isinstance(total, dict):
|
||||
return None
|
||||
context_tokens = total.get("inputTokens")
|
||||
cumulative_input_tokens = total.get("inputTokens")
|
||||
context_window = total.get("contextWindow")
|
||||
output_tokens = total.get("outputTokens")
|
||||
cached_input_tokens = total.get("cachedInputTokens")
|
||||
data: dict[str, int] = {}
|
||||
if isinstance(context_tokens, int) and context_tokens >= 0:
|
||||
data["context_tokens"] = context_tokens
|
||||
if isinstance(cumulative_input_tokens, int) and cumulative_input_tokens >= 0:
|
||||
# Codex's ``tokenUsage.total`` is CUMULATIVE across the whole thread
|
||||
# (the CLI subtracts prior totals to recover per-turn deltas), so
|
||||
# ``total.inputTokens`` / ``outputTokens`` are the session's cumulative
|
||||
# token counts. Forward them as the cumulative fields the server prices
|
||||
# into ``total_cost_usd`` (SET semantics) — codex-native produces no
|
||||
# ``response.completed``, so the Omnigent relay never accounts its cost.
|
||||
data["cumulative_input_tokens"] = context_tokens
|
||||
data["cumulative_input_tokens"] = cumulative_input_tokens
|
||||
# Codex's ``inputTokens`` is INCLUSIVE of cached tokens
|
||||
# (``non_cached_input = input_tokens - cached_input_tokens`` in
|
||||
# codex-rs ``protocol.rs``). Forward the cumulative cached count so the
|
||||
@@ -4530,6 +4529,18 @@ def _session_usage_data_from_params(params: dict[str, Any]) -> dict[str, int] |
|
||||
# cumulative (SET) semantics as ``cumulative_input_tokens``.
|
||||
if isinstance(cached_input_tokens, int) and cached_input_tokens >= 0:
|
||||
data["cumulative_cache_read_input_tokens"] = cached_input_tokens
|
||||
# ``context_tokens`` drives the context-window ring in the web UI. It
|
||||
# must reflect the CURRENT context occupancy (how much of the window
|
||||
# the latest turn consumed), NOT the cumulative total across all turns.
|
||||
# Codex's ``tokenUsage.last`` carries the per-turn breakdown; fall back
|
||||
# to ``total.inputTokens`` only when ``last`` is unavailable (first
|
||||
# frame before a turn completes).
|
||||
last = token_usage.get("last")
|
||||
last_input = last.get("inputTokens") if isinstance(last, dict) else None
|
||||
if isinstance(last_input, int) and last_input >= 0:
|
||||
data["context_tokens"] = last_input
|
||||
elif isinstance(cumulative_input_tokens, int) and cumulative_input_tokens >= 0:
|
||||
data["context_tokens"] = cumulative_input_tokens
|
||||
if isinstance(output_tokens, int) and output_tokens >= 0:
|
||||
data["cumulative_output_tokens"] = output_tokens
|
||||
if isinstance(context_window, int) and context_window > 0:
|
||||
|
||||
+82
-6
@@ -6703,6 +6703,80 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=200)
|
||||
|
||||
async def _handle_codex_native_compact(conv_id: str) -> Response:
|
||||
"""
|
||||
Type ``/compact`` into Codex's tmux pane.
|
||||
|
||||
Mirrors :func:`_handle_claude_native_compact` for codex-native
|
||||
sessions. Codex owns its own context window in the terminal,
|
||||
so explicit compaction must be injected as the ``/compact``
|
||||
slash command — the same rationale as the claude-native path.
|
||||
|
||||
The tmux pane coordinates come from the **resource registry**
|
||||
(not a ``tmux.json`` sidecar) because codex-native terminals
|
||||
are launched through the registry. This is the same resolution
|
||||
path :func:`_handle_codex_native_cost_popup` uses.
|
||||
|
||||
Returns 200 on successful injection so the Omnigent server
|
||||
knows the control was handled in the terminal and skips its
|
||||
own AP-side compaction. 204 when no live terminal is
|
||||
registered (the server falls back to in-process compaction).
|
||||
|
||||
:param conv_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:returns: 200 once ``/compact`` has been typed into the pane.
|
||||
204 if no live codex terminal is registered for the session.
|
||||
503 if the tmux send-keys invocation fails.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import _run_tmux
|
||||
|
||||
registry = resource_registry.terminal_registry
|
||||
instance = registry.get(conv_id, "codex", "main") if registry is not None else None
|
||||
if instance is None or not instance.running:
|
||||
# No live codex terminal — let the server run AP-side compaction.
|
||||
return Response(status_code=204)
|
||||
|
||||
socket_path = str(instance.socket_path)
|
||||
target = instance.tmux_target
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_inject_codex_compact, socket_path, target
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "codex_native_compact_failed",
|
||||
"detail": _client_safe_error_detail(exc, context="codex-native compact"),
|
||||
},
|
||||
)
|
||||
return Response(status_code=200)
|
||||
|
||||
def _inject_codex_compact(socket_path: str, target: str) -> None:
|
||||
"""
|
||||
Blocking helper: type ``/compact`` into a codex tmux pane.
|
||||
|
||||
Uses the same ``C-u`` → literal ``/compact`` → ``Enter``
|
||||
sequence that :func:`~omnigent.claude_native_bridge.inject_slash_command`
|
||||
uses for claude-native. Factored into its own function so
|
||||
:func:`_handle_codex_native_compact` can run it via
|
||||
``asyncio.to_thread`` without importing at call time.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket, e.g.
|
||||
``"/tmp/.../codex-main.sock"``.
|
||||
:param target: Tmux target pane, e.g. ``"main"``.
|
||||
:raises RuntimeError: If any ``tmux send-keys`` invocation fails.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import _run_tmux
|
||||
|
||||
# Clear any draft the user is mid-typing.
|
||||
_run_tmux(socket_path, "send-keys", "-t", target, "C-u")
|
||||
# Paste ``/compact`` literally.
|
||||
_run_tmux(socket_path, "send-keys", "-l", "-t", target, "/compact")
|
||||
# Submit.
|
||||
_run_tmux(socket_path, "send-keys", "-t", target, "Enter")
|
||||
|
||||
async def _handle_claude_native_cost_popup(
|
||||
conv_id: str,
|
||||
elicitation_id: str,
|
||||
@@ -9373,14 +9447,16 @@ def create_runner_app(
|
||||
|
||||
if body_type == "compact":
|
||||
# Omnigent server forwards explicit /compact here. claude-native
|
||||
# injects the slash command into the tmux pane so Claude
|
||||
# Code compacts its own context, and returns 200 to signal
|
||||
# the control was handled in the terminal. Other harnesses
|
||||
# 204 no-op — their explicit compaction is an AP-side
|
||||
# operation the server runs when the runner does not handle
|
||||
# the control (see ``_run_compact_locked``).
|
||||
# and codex-native inject the slash command into the tmux
|
||||
# pane so the CLI compacts its own context, and return 200
|
||||
# to signal the control was handled in the terminal. Other
|
||||
# harnesses 204 no-op — their explicit compaction is an
|
||||
# AP-side operation the server runs when the runner does
|
||||
# not handle the control (see ``_run_compact_locked``).
|
||||
if _session_harness_name(conversation_id) == "claude-native":
|
||||
return await _handle_claude_native_compact(conversation_id)
|
||||
if _session_harness_name(conversation_id) == "codex-native":
|
||||
return await _handle_codex_native_compact(conversation_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
if body_type == "cost_approval_popup":
|
||||
|
||||
@@ -8610,6 +8610,224 @@ async def test_events_compact_on_native_session_returns_503_when_bridge_not_read
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_compact_on_codex_native_injects_slash_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
POST ``/events`` with ``{"type":"compact"}`` on a codex-native
|
||||
session injects ``/compact`` into the codex tmux pane and returns 200.
|
||||
|
||||
Codex owns its own context window in the terminal, so explicit
|
||||
compaction must run inside Codex — the same rationale as the
|
||||
claude-native path. The pane coordinates come from the resource
|
||||
registry (not a ``tmux.json`` sidecar). The 200 return is
|
||||
load-bearing: the Omnigent server reads it to skip its own
|
||||
AP-side compaction.
|
||||
"""
|
||||
from omnigent.runner.app import _session_event_queues_ref
|
||||
from tests.runner.helpers import make_test_terminal_instance
|
||||
|
||||
captured: list[tuple[str, list[str]]] = []
|
||||
|
||||
def _fake_run_tmux(socket_path: str, *args: str) -> None:
|
||||
"""Record tmux send-keys calls without touching tmux."""
|
||||
captured.append((socket_path, list(args)))
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
|
||||
|
||||
codex_native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the codex-native spec for any agent_id."""
|
||||
del agent_id, session_id
|
||||
return codex_native_spec
|
||||
|
||||
conv_id = "conv_codex_compact"
|
||||
terminal_registry = TerminalRegistry()
|
||||
instance = make_test_terminal_instance("codex", "main", tmp_path)
|
||||
terminal_registry._by_conversation.setdefault(conv_id, {})[("codex", "main")] = instance
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
terminal_registry=terminal_registry,
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "ag_1"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
||||
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "compact"},
|
||||
)
|
||||
|
||||
# Drain the event queue: /compact is a control signal and must
|
||||
# not enqueue session.status events.
|
||||
queue = _session_event_queues_ref.get(conv_id)
|
||||
queued_events: list[dict[str, Any]] = []
|
||||
if queue is not None:
|
||||
while not queue.empty():
|
||||
item = queue.get_nowait()
|
||||
if isinstance(item, dict):
|
||||
queued_events.append(item)
|
||||
|
||||
# 200 = codex-native dispatch routed to the compact handler and it
|
||||
# injected successfully.
|
||||
assert resp.status_code == 200, (
|
||||
f"Codex-native compact must return 200 from /events; got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
# Exactly 3 tmux send-keys calls: C-u, -l /compact, Enter.
|
||||
assert len(captured) == 3, (
|
||||
f"Expected 3 tmux send-keys calls (C-u, /compact, Enter), got {len(captured)}."
|
||||
)
|
||||
socket = str(instance.socket_path)
|
||||
# 1. Clear draft: C-u
|
||||
assert captured[0] == (socket, ["send-keys", "-t", "main", "C-u"]), (
|
||||
f"First call must clear draft with C-u; got {captured[0]!r}."
|
||||
)
|
||||
# 2. Type /compact literally
|
||||
assert captured[1] == (socket, ["send-keys", "-l", "-t", "main", "/compact"]), (
|
||||
f"Second call must type /compact literally; got {captured[1]!r}."
|
||||
)
|
||||
# 3. Submit with Enter
|
||||
assert captured[2] == (socket, ["send-keys", "-t", "main", "Enter"]), (
|
||||
f"Third call must submit with Enter; got {captured[2]!r}."
|
||||
)
|
||||
# /compact is a control signal, not a state change.
|
||||
assert queued_events == [], f"compact must not publish session events; got {queued_events!r}."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_compact_on_codex_native_returns_204_when_no_terminal() -> None:
|
||||
"""
|
||||
Codex-native compact returns 204 when no live terminal is registered.
|
||||
|
||||
Without a running codex terminal the ``/compact`` slash command
|
||||
has nowhere to go. 204 tells the Omnigent server to fall back to
|
||||
its own AP-side compaction (or skip it).
|
||||
"""
|
||||
codex_native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the codex-native spec for any agent_id."""
|
||||
del agent_id, session_id
|
||||
return codex_native_spec
|
||||
|
||||
conv_id = "conv_codex_compact_no_term"
|
||||
# Empty registry — no codex terminal registered.
|
||||
terminal_registry = TerminalRegistry()
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
terminal_registry=terminal_registry,
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "ag_1"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "compact"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204, (
|
||||
f"Codex-native compact with no terminal must return 204; "
|
||||
f"got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_compact_on_codex_native_returns_503_on_tmux_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Codex-native compact returns 503 when the tmux send-keys call fails.
|
||||
|
||||
The 503 tells the Omnigent server the control was NOT handled, so it
|
||||
can surface an error rather than silently running its own (wrong)
|
||||
compaction.
|
||||
"""
|
||||
from tests.runner.helpers import make_test_terminal_instance
|
||||
|
||||
def _failing_run_tmux(socket_path: str, *args: str) -> None:
|
||||
"""Simulate a tmux pane that is no longer alive."""
|
||||
del socket_path, args
|
||||
raise RuntimeError("no server running on /tmp/dead.sock")
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _failing_run_tmux)
|
||||
|
||||
codex_native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the codex-native spec for any agent_id."""
|
||||
del agent_id, session_id
|
||||
return codex_native_spec
|
||||
|
||||
conv_id = "conv_codex_compact_fail"
|
||||
terminal_registry = TerminalRegistry()
|
||||
instance = make_test_terminal_instance("codex", "main", tmp_path)
|
||||
terminal_registry._by_conversation.setdefault(conv_id, {})[("codex", "main")] = instance
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
terminal_registry=terminal_registry,
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "ag_1"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "compact"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503, (
|
||||
f"Codex-native compact with tmux failure must return 503; "
|
||||
f"got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("error") == "codex_native_compact_failed", (
|
||||
f"503 body must carry the codex bridge-failure error code; got {body!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_compact_on_non_native_session_is_204_noop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -7302,6 +7302,84 @@ def test_session_usage_data_without_output_tokens_omits_cumulative_output() -> N
|
||||
assert "cumulative_output_tokens" not in data
|
||||
|
||||
|
||||
def test_session_usage_data_context_tokens_uses_last_turn_input() -> None:
|
||||
"""
|
||||
``context_tokens`` (the context-ring value) should reflect the LAST
|
||||
turn's input — how much of the window the latest request occupied —
|
||||
not the cumulative total across the whole thread.
|
||||
|
||||
When ``tokenUsage.last`` is present, ``context_tokens`` comes from
|
||||
``last.inputTokens``; ``cumulative_input_tokens`` still uses
|
||||
``total.inputTokens`` for cost pricing.
|
||||
"""
|
||||
params = {
|
||||
"tokenUsage": {
|
||||
"total": {
|
||||
"inputTokens": 4_800_000,
|
||||
"outputTokens": 200_000,
|
||||
"contextWindow": 1_178_000,
|
||||
},
|
||||
"last": {
|
||||
"inputTokens": 950_000,
|
||||
"outputTokens": 12_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
data = codex_native_forwarder._session_usage_data_from_params(params)
|
||||
assert data is not None
|
||||
# Ring shows current context occupancy from the last turn.
|
||||
assert data["context_tokens"] == 950_000
|
||||
# Cost pricing uses cumulative totals.
|
||||
assert data["cumulative_input_tokens"] == 4_800_000
|
||||
assert data["cumulative_output_tokens"] == 200_000
|
||||
assert data["context_window"] == 1_178_000
|
||||
|
||||
|
||||
def test_session_usage_data_context_tokens_falls_back_without_last() -> None:
|
||||
"""
|
||||
When ``tokenUsage.last`` is absent (e.g. first frame before a turn
|
||||
completes), ``context_tokens`` falls back to ``total.inputTokens``.
|
||||
"""
|
||||
params = {
|
||||
"tokenUsage": {
|
||||
"total": {
|
||||
"inputTokens": 1000,
|
||||
"outputTokens": 250,
|
||||
"contextWindow": 200_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
data = codex_native_forwarder._session_usage_data_from_params(params)
|
||||
assert data is not None
|
||||
assert data["context_tokens"] == 1000
|
||||
assert data["cumulative_input_tokens"] == 1000
|
||||
|
||||
|
||||
def test_session_usage_data_context_tokens_falls_back_when_last_missing_input() -> None:
|
||||
"""
|
||||
When ``tokenUsage.last`` is present but lacks a usable ``inputTokens``,
|
||||
``context_tokens`` falls back to ``total.inputTokens`` rather than being
|
||||
omitted (which would leave the UI ring stuck on a stale value from a
|
||||
previous coalescer frame).
|
||||
"""
|
||||
params = {
|
||||
"tokenUsage": {
|
||||
"total": {
|
||||
"inputTokens": 3000,
|
||||
"outputTokens": 500,
|
||||
"contextWindow": 200_000,
|
||||
},
|
||||
"last": {
|
||||
"outputTokens": 100,
|
||||
# inputTokens intentionally absent
|
||||
},
|
||||
},
|
||||
}
|
||||
data = codex_native_forwarder._session_usage_data_from_params(params)
|
||||
assert data is not None
|
||||
assert data["context_tokens"] == 3000
|
||||
|
||||
|
||||
def test_usage_coalescer_flush_attaches_model_to_every_post() -> None:
|
||||
"""
|
||||
``flush`` attaches the recorded model to each token-bearing post.
|
||||
|
||||
Reference in New Issue
Block a user