Compare commits

...

4 Commits

Author SHA1 Message Date
Tomu Hirata 7f55c52315 feat(codex-native): add native /compact support via tmux injection
Codex-native sessions now handle /compact by injecting the slash command
into the Codex tmux pane (via resource registry), matching the
claude-native pattern. Returns 200 so the server skips AP-side
compaction, 204 when no terminal is registered, 503 on tmux failure.

Co-authored-by: Isaac
2026-06-16 15:14:24 +09:00
Tomu Hirata 1bed90fd54 Merge branch 'main' of https://github.com/omnigent-ai/omnigent into fix/codex-native-context-ring-cumulative-tokens 2026-06-16 14:17:59 +09:00
Tomu Hirata b5500de2b7 fix: fall back to cumulative tokens when last.inputTokens is missing/invalid
When tokenUsage.last is present but lacks a usable inputTokens value,
fall back to total.inputTokens for context_tokens rather than omitting
it entirely (which would leave the ring stuck on a stale coalescer value).

Co-authored-by: Isaac
2026-06-16 13:40:31 +09:00
Tomu Hirata d2e5f86afd fix(codex-native): use per-turn input tokens for context ring instead of cumulative total
The context-window ring was showing 100% on long Codex sessions because
`context_tokens` was sourced from `tokenUsage.total.inputTokens` (cumulative
across all turns) rather than the current context occupancy. For a multi-turn
session the cumulative total easily exceeds the window (e.g. 4.8M vs 1.2M).

Read `context_tokens` from `tokenUsage.last.inputTokens` (per-turn breakdown
Codex already provides) so the ring reflects actual window usage. Falls back to
the cumulative total when `last` is absent (first frame before a turn completes).

Co-authored-by: Isaac
2026-06-16 13:36:49 +09:00
4 changed files with 393 additions and 10 deletions
+15 -4
View File
@@ -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
View File
@@ -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":
+218
View File
@@ -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,
+78
View File
@@ -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.