Files
omnigent-ai--omnigent/tests/test_claude_native_forwarder.py
Harry Yao 67f9db8bc8 Implement Claude Code permission mode switching (#4018)
* feat(web): switch claude-native permission mode mid-session

Claude Code's permission mode could only be chosen when starting a
session: `--permission-mode` is a launch flag, so a running session was
stuck in whatever mode it booted with. Switching to auto mode meant
either attaching to the tmux pane and pressing shift+tab, or ending the
session and starting a new one.

Claude Code exposes no non-interactive mode command (`/permissions`
opens an interactive rules dialog, and settings files are read only at
startup), so the switch drives the TUI's own shift+tab cycle:

- `claude_native_bridge.set_permission_mode` presses `BTab` and reads
  the mode footer Claude renders below its input box after each press,
  until the target mode appears. The cycle is walked rather than
  computed from a press count because its width varies — `auto` is only
  in it for accounts that have the mode, and `bypassPermissions` only
  when the session launched into it. `dontAsk`/`bypassPermissions` are
  rejected up front as unreachable.
- The runner dispatches a `permission_mode_change` event and echoes back
  the mode the pane actually landed on.
- PATCH /v1/sessions accepts `permission_mode` and persists it as a
  label only after the runner confirms the switch, so the UI can never
  claim auto mode while Claude is still prompting on every edit. The
  label (not `terminal_launch_args`, which records only launch flags) is
  what the UI reads back after a reload.
- The composer grows a mode picker for claude-native sessions, mirroring
  the existing Codex plan-mode toggle.

The prompting mode is relabelled "Manual" to match what Claude Code
calls it in its own UI and renders in the pane footer; the wire value
stays `default`.

Test Plan
- New bridge tests cover the walk, the no-op when already in the target
  mode, an unreachable mode, non-cycleable modes, a pane with no footer,
  and a stale footer mid-repaint (this last case was a real bug found
  against a live TUI: reading the pre-keystroke mode made the cycler lag
  a mode behind and report `auto` unreachable).
- New runner tests cover the dispatch, the 503 on a failed switch, and
  the non-native no-op; new server tests cover the forward, the
  label-only-after-confirmation rule, and both rejection paths.
- Verified end-to-end against a real `claude` 2.1.220 TUI in tmux:
  every mode reached and confirmed in the pane, including a repeated
  request for the mode already active.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* feat(web): mirror in-terminal permission-mode switches to the UI

A shift+tab pressed inside the Claude Code TUI never reached the web UI:
only UI-driven switches stamped the mode label, so the composer's picker
showed a stale mode until the next switch from the web side.

Claude Code emits no event on a mode change and hook payloads only arrive
on tool use, so the rendered mode footer is the only available signal. The
forwarder polls it (throttled to 2s — each read spawns a tmux capture-pane
subprocess) and POSTs `external_permission_mode_change` when it differs
from the last reported mode. The server persists the label and publishes
`session.permission_mode` so the live picker follows the pane and a
reloading client restores the same state.

Anchor the footer scan on the input box's closing rule instead of a fixed
tail offset: the footer's height scales with concurrent subagents, and the
anchor also excludes transcript text structurally, so a mode name Claude
echoed while discussing modes can't be misread as live.

The first observation after spawn is the launch mode, not a switch, so it
seeds the baseline without posting — otherwise a passive spawn default
could clobber a mode the web UI just set. An unreadable pane reads as
"unknown" rather than a guess, and the picker hides instead of displaying
a mode the session may not be in (a `permissions.defaultMode` in a
settings file never reaches the launch args).

Also move the picker into the session-config gear modal alongside model
and effort, keeping the composer row uncluttered.

Fix: `external_permission_mode_change` was missing from the events route's
payload-validation passthrough, so every forwarder POST was rejected with
a 400 before reaching its handler — and the forwarder swallows HTTP errors
at debug level, so the mirror failed silently. Added an end-to-end test
that drives the real forwarder against a live server and asserts the event
crosses the actual SSE wire, since each layer's own mocked tests passed
while the seam between them was broken.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test: drop redundant permission-mode cases and tighten comments

The `sse.test.ts` block duplicated `sessionEvents.test.ts`, which pins the
same envelope through `parseEventLines` and is the file whose stated job is
catching silent lift bugs. The composer case asserting the absence of the
old standalone picker guarded a selector that no longer exists anywhere.

Rewrite the comments that described the change instead of the code: the
e2e docstring recounted how the 400 was found, and a composer comment
narrated what the gear "used to" do.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(server): don't 500 on a silent permission-mode PATCH

`silent` suppresses the live runner forward, so no mode is confirmed and no
label is written — but the publish read `labels_to_set[...]` unconditionally
and raised KeyError, turning a request that changed nothing into a 500.

Gate the publish on the label actually being set. That also keeps an
unconfirmed mode off the picker, matching the rule the forward path already
follows: the label is written only once the pane really moved.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(ci): address all 4 failing CI checks on PR #4018

- Regenerate openapi.json to include the new SessionPermissionModeEvent
  type and permission_mode field on UpdateSessionRequest; fixes Pytest
  (server-rest) openapi drift check.

- Update E2E landing-page permission-mode test: the default mode label
  was renamed from "Default" to "Manual" in claudePermissionMode.ts;
  fixes E2E UI Tests (shard 2/3).

- Add test_claude_native_permission_mode_switch_persists to cover the
  new in-chat permission-mode picker in the gear modal; extends
  _patch_session_as_claude_native with a permission_mode param so the
  label stub is set; resolves the E2E UI Required gate.

- Raise the asyncio yield deadline in the quiescence-recheck test from
  10 000 to 50 000 to eliminate flaky timing failures on slower CI
  runners; fixes Pytest (misc).

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(claude-native): show the permission picker for manual-mode sessions

A session launched in manual mode had no way to leave it. Manual is the
default and writes no `--permission-mode` arg, and the forwarder discarded
its first pane observation as "just the launch mode", so nothing ever
recorded the session's mode. With no mode to render the web picker hid
itself, and the switch could never be started — not because cycling
failed, but because there was no control to press.

Post the first observation instead of seeding silently, so every live
claude-native session publishes its mode within one poll whatever it
launched in, CLI `omnigent claude` included. The overwrite this guarded
against cannot happen: the server ignores a mode equal to the stored
label, and the PATCH path only persists after the runner confirms the
pane repainted, so a stored label already matches the pane.

Verified against a real Claude Code 2.1.237 pane: manual renders
`⏸ manual mode on`, and shift+tab cycles manual → accept edits → plan →
auto → manual, matching the bridge's footer table exactly.

Three tests had encoded seed-silently as intended behavior and now assert
the launch mode reaching the wire.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test(e2e): locate permission-mode options by data attribute

The picker's options render their label and description in nested spans, so
each option's accessible name is "Auto Auto-runs; a classifier blocks risky
actions" — never the bare label. `get_by_role("option", name="Auto",
exact=True)` could therefore never match, and the click timed out after 30s.

Tag each option with `data-permission-mode` and select on that, matching how
the sibling model and effort rows already expose `data-model-id` and
`data-effort-level` for the same reason.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

---------

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-20 23:14:53 -07:00

8417 lines
310 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.
"""Tests for native Claude transcript forwarding."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import queue
import threading
from collections.abc import Callable, Generator, Iterator
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import omnigent.claude_native_forwarder as forwarder
from omnigent.claude_native_bridge import (
BRIDGE_ID_LABEL_KEY,
ClaudeMessageDelta,
ClaudeTranscriptItem,
prepare_bridge_dir,
read_active_session_id,
record_hook_event,
write_active_session_id,
)
from omnigent.claude_native_forwarder import (
CompactionForwardState,
_claim_standalone_completion,
_consume_pending_compaction,
_handle_compact_summary_item,
_note_precompact,
_persist_native_compaction_item,
_PostRetryTracker,
_prescan_precompact_edges,
_read_compaction_state,
_reset_compaction_skip_stats,
forward_claude_transcript_to_session,
)
from omnigent.reasoning_effort import CLAUDE_EFFORTS, EFFORT_CLEAR_VALUES
@pytest.fixture(autouse=True)
def _allow_tmp_path_as_bridge_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""
Treat each test's temp dir as the Claude bridge root.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp directory.
:returns: None.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path)
class _RecordingHTTPServer(ThreadingHTTPServer):
"""
HTTP server that records JSON POST bodies.
:param server_address: Host/port tuple for
:class:`ThreadingHTTPServer`.
:param RequestHandlerClass: Handler class used for requests.
"""
requests: queue.Queue[dict[str, Any]]
def _handler_factory(
requests: queue.Queue[dict[str, Any]],
) -> type[BaseHTTPRequestHandler]:
"""
Create a request handler that records POST JSON.
:param requests: Queue receiving decoded request records.
:returns: A concrete :class:`BaseHTTPRequestHandler` subclass.
"""
class _Handler(BaseHTTPRequestHandler):
"""Request handler for the test Omnigent endpoint."""
def log_message(self, format: str, *args: Any) -> None:
"""
Suppress test HTTP server logging.
:param format: Log format string.
:param args: Log format arguments.
:returns: None.
"""
del format, args
def do_POST(self) -> None:
"""
Record a JSON POST body and return HTTP 202.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
requests.put(
{
"method": "POST",
"path": self.path,
"body": json.loads(raw.decode("utf-8")),
"authorization": self.headers.get("Authorization"),
}
)
self.send_response(202)
self.end_headers()
self.wfile.write(b"{}")
def do_PATCH(self) -> None:
"""
Record a JSON PATCH body and return HTTP 200.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
requests.put(
{
"method": "PATCH",
"path": self.path,
"body": json.loads(raw.decode("utf-8")),
"authorization": self.headers.get("Authorization"),
}
)
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
return _Handler
def _start_recording_server() -> tuple[_RecordingHTTPServer, threading.Thread, str]:
"""
Start a local HTTP server that records POST bodies.
:returns: ``(server, thread, base_url)``.
"""
requests: queue.Queue[dict[str, Any]] = queue.Queue()
server = _RecordingHTTPServer(("127.0.0.1", 0), _handler_factory(requests))
server.requests = requests
thread = threading.Thread(
target=server.serve_forever,
name="claude-forwarder-test-ap",
daemon=True,
)
thread.start()
host, port = server.server_address
return server, thread, f"http://{host}:{port}"
async def _get_recorded_request(
server: _RecordingHTTPServer,
*,
timeout_s: float = 5.0,
method: str = "POST",
) -> dict[str, Any]:
"""
Await one recorded request from the test server, filtered by method.
The forwarder mirrors Claude's native session id to Omnigent via a
one-shot ``PATCH /v1/sessions/{id}`` (see
:func:`_maybe_mirror_external_session_id`). Most tests in this
file assert on POSTs to ``/events``; defaulting the filter to
``"POST"`` lets those tests skip the mirroring PATCH that lands
at the start of every loop in which the bridge state carries a
Claude session id. PATCH-specific tests pass ``method="PATCH"``.
:param server: Recording HTTP server.
:param timeout_s: Maximum seconds to wait — applied per
``queue.get`` call, so the helper can spend up to
``timeout_s`` skipping each non-matching request before
giving up on the next matching one.
:param method: HTTP method to filter for, e.g. ``"POST"`` or
``"PATCH"``. Non-matching requests are silently discarded.
:returns: Recorded request dict whose ``method`` matches.
"""
while True:
try:
request = await asyncio.to_thread(server.requests.get, True, timeout_s)
except queue.Empty as exc:
raise AssertionError(
f"forwarder did not produce a {method} request",
) from exc
if request.get("method") == method:
return request
async def _get_recorded_item_request(
server: _RecordingHTTPServer,
*,
timeout_s: float = 5.0,
) -> dict[str, Any]:
"""
Await the next ``external_conversation_item`` POST, skipping status edges.
The forwarder now emits a turn-start ``external_session_status: running``
(carrying the turn's response id, which drives the live tool-card spinner
in ap-web) BEFORE a turn's items each poll. Tests that only care about the
forwarded conversation items use this to skip that leading status edge (and
any trailing idle) without asserting on it.
:param server: Recording HTTP server.
:param timeout_s: Per-``get`` timeout while skipping non-item POSTs.
:returns: The next recorded ``external_conversation_item`` POST.
"""
while True:
request = await _get_recorded_request(server, timeout_s=timeout_s)
if request["body"].get("type") == "external_conversation_item":
return request
async def _wait_for_json_file(path: Path, *, timeout_s: float = 5.0) -> dict[str, Any]:
"""
Wait until a JSON object file exists and can be parsed.
:param path: JSON file path.
:param timeout_s: Maximum seconds to wait.
:returns: Parsed JSON object.
"""
deadline = asyncio.get_running_loop().time() + timeout_s
while asyncio.get_running_loop().time() < deadline:
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
assert isinstance(payload, dict)
return payload
await asyncio.sleep(0.01)
raise AssertionError(f"{path} was not written")
async def _wait_for_json_state(
path: Path,
predicate: Callable[[dict[str, Any]], bool],
*,
timeout_s: float = 5.0,
) -> dict[str, Any]:
"""
Wait until a JSON object file satisfies ``predicate``.
:param path: JSON file path.
:param predicate: Function returning ``True`` for the desired
state, e.g. ``lambda payload: "byte_offset" in payload``.
:param timeout_s: Maximum seconds to wait.
:returns: Parsed JSON object satisfying the predicate.
"""
deadline = asyncio.get_running_loop().time() + timeout_s
last_payload: dict[str, Any] | None = None
while asyncio.get_running_loop().time() < deadline:
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
assert isinstance(payload, dict)
last_payload = payload
if predicate(payload):
return payload
await asyncio.sleep(0.01)
raise AssertionError(f"{path} did not reach expected state; last={last_payload!r}")
@pytest.mark.asyncio
async def test_clear_hook_rotates_active_session_without_reprocessing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Claude ``/clear`` creates a fresh Omnigent session and consumes the hook.
This exercises the rotation transaction directly: create the new
session, bind the same runner, transfer the terminal, rewrite the
active bridge session, clear the old runner binding, and keep the
hook cursor past the clear record so the next poll does not fork
again from the same hook line.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
(bridge_dir / "transcript_forwarder.json").write_text("{}", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "clear",
},
)
calls: list[tuple[str, str, dict[str, Any] | None]] = []
def handler(request: httpx.Request) -> httpx.Response:
"""
Mock the Omnigent session-rotation endpoints.
:param request: Incoming request.
:returns: Canned Omnigent response.
"""
body = json.loads(request.content.decode("utf-8")) if request.content else None
calls.append((request.method, request.url.path, body))
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {
"omnigent.ui": "terminal",
BRIDGE_ID_LABEL_KEY: "bridge_shared",
},
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
assert body == {
"agent_id": "ag_claude",
"labels": {
"omnigent.ui": "terminal",
BRIDGE_ID_LABEL_KEY: "bridge_shared",
},
}
return httpx.Response(201, json={"id": "conv_new"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_new":
assert body == {"runner_id": "runner_one"}
return httpx.Response(200, json={"id": "conv_new"})
if (
request.method == "POST"
and request.url.path
== "/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer"
):
assert body == {"target_session_id": "conv_new"}
return httpx.Response(200, json={"id": "terminal_claude_main"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_old":
assert body == {
"runner_id": "",
"labels": {BRIDGE_ID_LABEL_KEY: "conv_old-cleared"},
}
return httpx.Response(200, json={"id": "conv_old"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
rotated_to = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=hook_state,
)
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_new",
)
rotated_again = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_new",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to == "conv_new"
assert rotated_again is None
assert read_active_session_id(bridge_dir) == "conv_new"
assert not (bridge_dir / "transcript_forwarder.json").exists()
assert (bridge_dir / "hook_forwarder.json").exists()
assert calls == [
("GET", "/v1/sessions/conv_old", None),
(
"POST",
"/v1/sessions",
{
"agent_id": "ag_claude",
"labels": {
"omnigent.ui": "terminal",
BRIDGE_ID_LABEL_KEY: "bridge_shared",
},
},
),
("PATCH", "/v1/sessions/conv_new", {"runner_id": "runner_one"}),
(
"POST",
"/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer",
{"target_session_id": "conv_new"},
),
(
"PATCH",
"/v1/sessions/conv_old",
{"runner_id": "", "labels": {BRIDGE_ID_LABEL_KEY: "conv_old-cleared"}},
),
]
@pytest.mark.asyncio
async def test_clear_hook_rotation_survives_old_runner_clear_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Old runner-binding cleanup failure must not retry the fork.
Once the terminal transfer succeeds and the bridge active session is
updated, retrying the whole rotation would create duplicate fresh
sessions from the same ``/clear`` hook. The stale old runner binding
is cleanup only; the executor active-session guard prevents stale
old-session writes from reaching tmux.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "clear",
},
)
create_count = 0
def handler(request: httpx.Request) -> httpx.Response:
"""
Mock Omnigent rotation endpoints with a failing old-session cleanup.
:param request: Incoming request.
:returns: Canned Omnigent response.
"""
nonlocal create_count
body = json.loads(request.content.decode("utf-8")) if request.content else None
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {BRIDGE_ID_LABEL_KEY: "bridge_shared"},
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
create_count += 1
return httpx.Response(201, json={"id": "conv_new"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_new":
assert body == {"runner_id": "runner_one"}
return httpx.Response(200, json={"id": "conv_new"})
if (
request.method == "POST"
and request.url.path
== "/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer"
):
assert body == {"target_session_id": "conv_new"}
return httpx.Response(200, json={"id": "terminal_claude_main"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_old":
assert body == {
"runner_id": "",
"labels": {BRIDGE_ID_LABEL_KEY: "conv_old-cleared"},
}
return httpx.Response(503, json={"error": {"message": "temporary failure"}})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
rotated_to = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=hook_state,
)
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_new",
)
rotated_again = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_new",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to == "conv_new"
assert rotated_again is None
assert create_count == 1
assert read_active_session_id(bridge_dir) == "conv_new"
@pytest.mark.asyncio
async def test_clear_hook_transfer_failure_does_not_loop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A terminal-transfer failure during /clear must NOT spin into a session loop.
Regression guard for the unbounded-session-creation bug: when the terminal
transfer fails (e.g. 400 because the target already owns a terminal), the
rotation must still consume the clear hook so the forwarder's next poll does
not re-rotate and create another replacement session every tick.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
record_hook_event(
bridge_dir,
{"hook_event_name": "SessionStart", "source": "clear"},
)
create_count = 0
def handler(request: httpx.Request) -> httpx.Response:
"""Mock rotation endpoints with a failing terminal transfer."""
nonlocal create_count
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {BRIDGE_ID_LABEL_KEY: "bridge_shared"},
},
)
if request.method == "POST" and request.url.path == "/v1/sessions":
create_count += 1
return httpx.Response(201, json={"id": "conv_new"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_new":
return httpx.Response(200, json={"id": "conv_new"})
if (
request.method == "POST"
and request.url.path
== "/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer"
):
# The failure that triggered the production loop.
return httpx.Response(400, json={"error": {"message": "Terminal already exists"}})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
# The transfer 400 is swallowed: rotation reports no new session...
rotated_to = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=hook_state,
)
# ...and a second poll must NOT re-rotate (the clear hook was consumed).
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
rotated_again = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to is None
assert rotated_again is None
# Exactly one replacement-session create — not one per poll.
assert create_count == 1
@pytest.mark.asyncio
async def test_post_clear_supersession_notifies_old_session() -> None:
"""
A /clear rotation notifies the superseded (old) conversation.
It POSTs, in order, (1) ``external_session_status: idle`` so the old
chat's spinner stops once its terminal moves away, (2) a persisted
assistant ``message`` item linking to the new conversation so a reload
explains the clear, and (3) a transient ``external_session_superseded``
redirect event so a live viewer auto-follows. All three are addressed
to the OLD conversation.
"""
calls: list[tuple[str, str, dict[str, Any] | None]] = []
def handler(request: httpx.Request) -> httpx.Response:
"""Record each POST and return a benign success."""
body = json.loads(request.content.decode("utf-8")) if request.content else None
calls.append((request.method, request.url.path, body))
return httpx.Response(200, json={"queued": False, "item_id": "item_x"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
await forwarder._post_clear_supersession(
client,
old_session_id="conv_old",
new_session_id="conv_new",
agent_name="claude-native-ui",
)
assert len(calls) == 3
# Every post is addressed to the OLD conversation.
assert all(
(method, path) == ("POST", "/v1/sessions/conv_old/events") for method, path, _ in calls
)
_, _, status_body = calls[0]
assert status_body == {
"type": "external_session_status",
"data": {"status": "idle"},
}
_, _, notice_body = calls[1]
assert notice_body is not None
assert notice_body["type"] == "external_conversation_item"
assert notice_body["data"]["item_type"] == "message"
item_data = notice_body["data"]["item_data"]
assert item_data["role"] == "assistant"
assert item_data["agent"] == "claude-native-ui"
notice_text = item_data["content"][0]["text"]
assert "/clear" in notice_text
assert "/c/conv_new" in notice_text
_, _, event_body = calls[2]
assert event_body == {
"type": "external_session_superseded",
"data": {"target_conversation_id": "conv_new"},
}
@pytest.mark.asyncio
async def test_post_clear_supersession_skips_when_old_equals_new() -> None:
"""
The notify is a no-op when the old and new ids collapse to one.
A defensive guard: addressing the "you were cleared" banner + redirect
at the live session id would dump them onto the active chat.
"""
calls: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
"""Fail loudly — no POST should happen."""
calls.append((request.method, request.url.path))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
await forwarder._post_clear_supersession(
client,
old_session_id="conv_same",
new_session_id="conv_same",
agent_name="claude-native-ui",
)
assert calls == []
@pytest.mark.asyncio
async def test_post_clear_supersession_swallows_post_failure() -> None:
"""
A failed notice/redirect POST is swallowed, not raised.
The rotation has already completed and reset forwarder state by the
time this runs, so a notification error must not break the poll loop.
"""
def handler(request: httpx.Request) -> httpx.Response:
"""Fail every POST so both best-effort calls hit their except path."""
return httpx.Response(500, json={"error": {"message": "boom"}})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
# Must not raise despite both POSTs returning 500.
await forwarder._post_clear_supersession(
client,
old_session_id="conv_old",
new_session_id="conv_new",
agent_name="claude-native-ui",
)
@pytest.mark.asyncio
async def test_clear_hook_consumes_hook_rotated_session_without_duplicate_fork(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Forwarder does not fork again when the SessionStart hook already did.
The synchronous hook rotates before printing Claude's welcome URL.
It annotates the hook record so the background forwarder only
advances its durable cursor and resets transcript state.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
write_active_session_id(bridge_dir, "conv_new")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "clear",
"omnigent_clear_rotated_to": "conv_new",
},
)
def handler(request: httpx.Request) -> httpx.Response:
"""
Fail if the forwarder tries to create another replacement session.
:param request: Incoming request.
:returns: Never returns.
"""
raise AssertionError(f"unexpected Omnigent request: {request.method} {request.url}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_new",
)
rotated_to = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_new",
bridge_dir=bridge_dir,
state=hook_state,
)
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_new",
)
rotated_again = await forwarder._maybe_rotate_session_on_clear(
client=client,
session_id="conv_new",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to == "conv_new"
assert rotated_again is None
assert read_active_session_id(bridge_dir) == "conv_new"
assert (bridge_dir / "hook_forwarder.json").exists()
@pytest.mark.asyncio
async def test_fork_hook_creates_omnigent_fork_and_consumes_hook(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Claude ``/fork`` creates an Omnigent fork and consumes the hook.
This exercises the branch/fork transaction directly: fork the AP
session, bind the same runner, transfer the terminal, rewrite the
active bridge session, clear the old runner binding, and advance
the hook cursor so the same hook line is not processed again.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
(bridge_dir / "transcript_forwarder.json").write_text("{}", encoding="utf-8")
transcript_path = tmp_path / "fork.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "attachment",
"timestamp": "2026-05-27T22:53:13.245Z",
"sessionId": "claude_fork",
"forkedFrom": {"sessionId": "claude_old"},
}
)
+ "\n",
encoding="utf-8",
)
monkeypatch.setattr("omnigent.claude_native_bridge.time.time", lambda: 1779922393.245)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "resume",
"session_id": "claude_fork",
"transcript_path": str(transcript_path),
"omnigent_previous_claude_session_id": "claude_old",
"omnigent_claude_session_was_seen": False,
},
)
calls: list[tuple[str, str, dict[str, Any] | None]] = []
def handler(request: httpx.Request) -> httpx.Response:
"""
Mock the Omnigent fork-rotation endpoints.
:param request: Incoming request.
:returns: Canned Omnigent response.
"""
body = json.loads(request.content.decode("utf-8")) if request.content else None
calls.append((request.method, request.url.path, body))
if request.method == "GET" and request.url.path == "/v1/sessions/conv_old":
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {
"omnigent.ui": "terminal",
BRIDGE_ID_LABEL_KEY: "bridge_shared",
},
},
)
if request.method == "POST" and request.url.path == "/v1/sessions/conv_old/fork":
assert body == {}
return httpx.Response(201, json={"id": "conv_fork"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_fork":
assert body == {"runner_id": "runner_one"}
return httpx.Response(200, json={"id": "conv_fork"})
if (
request.method == "POST"
and request.url.path
== "/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer"
):
assert body == {"target_session_id": "conv_fork"}
return httpx.Response(200, json={"id": "terminal_claude_main"})
if request.method == "PATCH" and request.url.path == "/v1/sessions/conv_old":
assert body == {"runner_id": ""}
return httpx.Response(200, json={"id": "conv_old"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
rotated_to = await forwarder._maybe_rotate_session_on_fork(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=hook_state,
)
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_fork",
)
rotated_again = await forwarder._maybe_rotate_session_on_fork(
client=client,
session_id="conv_fork",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to == "conv_fork"
assert rotated_again is None
assert read_active_session_id(bridge_dir) == "conv_fork"
transcript_state = json.loads(
(bridge_dir / "transcript_forwarder.json").read_text(encoding="utf-8")
)
assert transcript_state["transcript_path"] == str(transcript_path)
assert transcript_state["byte_offset"] == transcript_path.stat().st_size
assert (bridge_dir / "hook_forwarder.json").exists()
assert calls == [
("GET", "/v1/sessions/conv_old", None),
("POST", "/v1/sessions/conv_old/fork", {}),
("PATCH", "/v1/sessions/conv_fork", {"runner_id": "runner_one"}),
(
"POST",
"/v1/sessions/conv_old/resources/terminals/terminal_claude_main/transfer",
{"target_session_id": "conv_fork"},
),
("PATCH", "/v1/sessions/conv_old", {"runner_id": ""}),
]
@pytest.mark.asyncio
async def test_fork_hook_consumes_hook_rotated_session_without_duplicate_fork(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Forwarder does not fork again when the SessionStart hook already did.
The synchronous hook annotates the branch record with the forked AP
session id. The background forwarder only advances its durable
cursor and seeds transcript state past Claude's copied fork
history.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
write_active_session_id(bridge_dir, "conv_fork")
transcript_path = tmp_path / "fork.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"message": {"role": "user", "content": "already copied"},
"sessionId": "claude_fork",
"forkedFrom": {"sessionId": "claude_old"},
}
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "resume",
"transcript_path": str(transcript_path),
"omnigent_fork_detected": True,
"omnigent_fork_rotated_to": "conv_fork",
},
)
def handler(request: httpx.Request) -> httpx.Response:
"""
Fail if the forwarder tries to create another fork.
:param request: Incoming request.
:returns: Never returns.
"""
raise AssertionError(f"unexpected Omnigent request: {request.method} {request.url}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_fork",
)
rotated_to = await forwarder._maybe_rotate_session_on_fork(
client=client,
session_id="conv_fork",
bridge_dir=bridge_dir,
state=hook_state,
)
replay_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_fork",
)
rotated_again = await forwarder._maybe_rotate_session_on_fork(
client=client,
session_id="conv_fork",
bridge_dir=bridge_dir,
state=replay_state,
)
assert rotated_to == "conv_fork"
assert rotated_again is None
assert read_active_session_id(bridge_dir) == "conv_fork"
transcript_state = json.loads(
(bridge_dir / "transcript_forwarder.json").read_text(encoding="utf-8")
)
assert transcript_state["transcript_path"] == str(transcript_path)
assert transcript_state["byte_offset"] == transcript_path.stat().st_size
assert (bridge_dir / "hook_forwarder.json").exists()
@pytest.mark.asyncio
async def test_resume_seen_claude_fork_does_not_create_second_omnigent_fork(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Resuming an already-seen Claude branch does not create another Omnigent fork.
Claude branch transcripts retain ``forkedFrom`` metadata forever.
This test fails if the forwarder treats that historical marker
alone as a fresh `/fork` command after the hook recorded that the
incoming Claude session had already been seen.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
transcript_path = tmp_path / "fork.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "attachment",
"timestamp": "2026-05-27T22:53:13.245Z",
"sessionId": "claude_fork",
"forkedFrom": {"sessionId": "claude_old"},
}
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "resume",
"session_id": "claude_fork",
"transcript_path": str(transcript_path),
"omnigent_previous_claude_session_id": "claude_old",
"omnigent_claude_session_was_seen": True,
},
)
def handler(request: httpx.Request) -> httpx.Response:
"""
Fail if the forwarder tries to create another Omnigent fork.
:param request: Incoming request.
:returns: Never returns.
"""
raise AssertionError(f"unexpected Omnigent request: {request.method} {request.url}")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir,
start_at_end=False,
session_id="conv_old",
)
rotated_to = await forwarder._maybe_rotate_session_on_fork(
client=client,
session_id="conv_old",
bridge_dir=bridge_dir,
state=hook_state,
)
assert rotated_to is None
assert read_active_session_id(bridge_dir) == "conv_old"
@pytest.mark.asyncio
async def test_forwarder_posts_visible_transcript_items(tmp_path: Path) -> None:
"""
The background forwarder reads Claude JSONL and posts Omnigent items.
This catches the real-Claude failure where a terminal-originated
prompt/tool/output sequence was written to Claude's transcript
but no process tailed that transcript into the Omnigent session
stream.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "user-1",
"message": {"role": "user", "content": "read TODO"},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-tool-1",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_read_1",
"name": "Read",
"input": {"file_path": "TODO.md"},
}
],
},
}
),
json.dumps(
{
"type": "user",
"uuid": "tool-result-1",
"parentUuid": "assistant-tool-1",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_read_1",
"content": "todo contents",
}
],
},
}
),
json.dumps(
{
"type": "attachment",
"uuid": "queued-stop",
"attachment": {
"type": "queued_command",
"prompt": "STOP",
"commandMode": "prompt",
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-text-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "hello from transcript"}],
},
}
),
json.dumps(
{
"type": "user",
"subtype": "local_command",
"uuid": "bash-input-1",
"content": "<bash-input>pwd</bash-input>",
}
),
json.dumps(
{
"type": "user",
"subtype": "local_command",
"uuid": "bash-output-1",
"content": (
"<bash-stdout>/tmp/project</bash-stdout><bash-stderr></bash-stderr>"
),
}
),
]
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Collect the seven transcript items. The transcript path publishes no
# session status at all — Claude's status file owns the badge — which
# ``test_forwarder_publishes_no_status_for_assistant_output`` asserts
# directly.
requests = [await _get_recorded_item_request(server) for _index in range(7)]
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert [request["path"] for request in requests] == ["/v1/sessions/conv_abc/events"] * 7
assert [request["body"]["type"] for request in requests] == ["external_conversation_item"] * 7
posted = [request["body"]["data"] for request in requests]
assert [item["item_type"] for item in posted] == [
"message",
"function_call",
"function_call_output",
"message",
"message",
"terminal_command",
"terminal_command",
]
assert posted[0]["item_data"] == {
"role": "user",
"content": [{"type": "input_text", "text": "read TODO"}],
}
assert posted[1]["item_data"]["name"] == "Read"
assert posted[1]["item_data"]["call_id"] == "toolu_read_1"
assert posted[2]["item_data"] == {"call_id": "toolu_read_1", "output": "todo contents"}
assert posted[3]["item_data"] == {
"role": "user",
"content": [{"type": "input_text", "text": "STOP"}],
}
assert posted[4]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "hello from transcript"}],
}
assert posted[5]["item_data"] == {"kind": "input", "input": "pwd"}
assert posted[6]["item_data"] == {
"kind": "output",
"stdout": "/tmp/project",
"stderr": "",
}
assert posted[1]["response_id"] == posted[2]["response_id"]
assert posted[3]["response_id"] != posted[2]["response_id"]
assert posted[4]["response_id"] != posted[2]["response_id"]
assert posted[5]["response_id"] == posted[6]["response_id"]
assert posted[5]["response_id"] != posted[4]["response_id"]
assert posted[1]["response_id"].startswith("resp_claude_")
@pytest.mark.asyncio
async def test_forwarder_mirrors_interrupt_marker_for_ui(tmp_path: Path) -> None:
"""
End-to-end: Claude's ``[Request interrupted by user]`` IS mirrored to AP.
Drives the real forwarder over a transcript where the operator interrupts
a turn (Claude writes its own ``[Request interrupted by user]`` user
record) and then sends a follow-up. We deliberately keep the marker in
history so a reload still shows the interruption; the web UI re-classifies
it as a muted "System: Interrupted" marker (``parseSystemMessage``) rather
than a raw user bubble. Guards against re-adding a forwarder-side drop
filter, which would starve the UI of the marker.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "user-1",
"message": {"role": "user", "content": "write an essay"},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Once upon a"}],
},
}
),
# Operator pressed Escape — Claude's own interrupt record.
json.dumps(
{
"type": "user",
"uuid": "interrupt-1",
"message": {"role": "user", "content": "[Request interrupted by user]"},
}
),
json.dumps(
{
"type": "user",
"uuid": "user-2",
"message": {"role": "user", "content": "never mind, say hi"},
}
),
]
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# All 4 items reach AP, in order: user, assistant, the interrupt
# marker, follow-up user. The marker is kept (the UI renders it as a
# system marker); if a drop filter regressed, only 3 would post and
# the 4th collection would hang past the timeout.
requests = [await _get_recorded_request(server) for _index in range(4)]
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
posted = [
request["body"]["data"]
for request in requests
if request["body"]["type"] == "external_conversation_item"
]
texts = [item["item_data"]["content"][0]["text"] for item in posted]
assert texts == [
"write an essay",
"Once upon a",
"[Request interrupted by user]",
"never mind, say hi",
], f"Forwarder must mirror all turns including the interrupt marker; got {texts!r}"
@pytest.mark.asyncio
async def test_forwarder_posts_web_injected_terminal_transcript_items(tmp_path: Path) -> None:
"""
Web-injected messages still surface only after Claude records them.
The ``claude-native`` executor no longer owns transcript streaming
for Omnigent turns. This fails if a leftover pause/cursor path suppresses
terminal-originated output after a web message was typed into Claude.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "owned by executor"}],
}
}
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# The item posts FIRST: the transcript path publishes no status at all
# (Claude's status file owns the badge), so nothing precedes it.
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["path"] == "/v1/sessions/conv_abc/events"
assert request["body"]["type"] == "external_conversation_item"
assert request["body"]["data"]["item_type"] == "message"
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "owned by executor"}],
}
@pytest.mark.asyncio
async def test_forwarder_posts_idle_on_stop_and_ignores_user_prompt_submit(
tmp_path: Path,
) -> None:
"""
``Stop`` → idle (the authoritative turn-end); ``UserPromptSubmit`` ignored.
``Stop`` is the fire-once turn-end edge that drives sub-agent terminal
delivery (via ``external_session_status``, the codex-shared path). The
``running`` edge stays PTY-derived, so ``UserPromptSubmit`` must NOT post a
status. We record ``UserPromptSubmit`` ahead of ``Stop``: the first (and
only) ``external_session_status`` POST must be the ``idle`` from ``Stop``.
A ``running`` arriving first would mean ``UserPromptSubmit`` still maps.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "UserPromptSubmit", "session_id": "claude-session"},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "Stop", "session_id": "claude-session"},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# The first (and only) status POST is the Stop → idle. A ``running``
# arriving first would mean UserPromptSubmit is still wrongly mapped.
assert request["path"] == "/v1/sessions/conv_abc/events"
# The Stop hook carries its authoritative background-shell count (0 here,
# no background tasks) so a finished shell clears the indicator.
assert request["body"] == {
"type": "external_session_status",
"data": {"status": "idle", "background_task_count": 0},
}
@pytest.mark.asyncio
async def test_forwarder_ignores_subagent_stop_failure_hook(
tmp_path: Path,
) -> None:
"""
A subagent's ``StopFailure`` must not flip the parent session failed.
Claude Code subagents (spawned via the Agent tool for e.g. Explore)
inherit the parent's hook settings and write to the same
``hooks.jsonl``. A subagent failing must not mark the *parent* turn
failed — the parent is still running while it awaits the Agent tool
result. Subagent transcripts live under a ``subagents/`` directory,
which the forwarder uses to distinguish them from parent events.
(Running/idle are no longer hook-derived; ``StopFailure`` →
``failed`` is the only mapped status left, so this is the surviving
subagent-skip case.)
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
subagent_transcript = tmp_path / "session" / "subagents" / "agent-abc.jsonl"
subagent_transcript.parent.mkdir(parents=True, exist_ok=True)
subagent_transcript.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "parent-session",
"transcript_path": str(transcript_path),
},
)
# Subagent fails first — this must NOT surface as the parent failing.
record_hook_event(
bridge_dir,
{
"hook_event_name": "StopFailure",
"session_id": "subagent-session",
"transcript_path": str(subagent_transcript),
},
)
# Parent turn fails — this SHOULD surface as the one failed edge.
record_hook_event(
bridge_dir,
{
"hook_event_name": "StopFailure",
"session_id": "parent-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Exactly one status POST: the parent's failed. The subagent
# StopFailure (recorded first) must be skipped, so no second
# status POST ever arrives — the bounded wait below must time out.
first = await _get_recorded_request(server)
with pytest.raises(AssertionError):
await _get_recorded_request(server, timeout_s=0.5)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert first["body"] == {
"type": "external_session_status",
"data": {"status": "failed"},
}
@pytest.mark.asyncio
async def test_forwarder_ignores_subagent_stop_hook(
tmp_path: Path,
) -> None:
"""
A subagent's ``Stop`` must not deliver the parent session as idle.
Claude Code Task subagents inherit the parent's hook settings and write to
the same ``hooks.jsonl``. A subagent finishing must NOT post ``idle`` for
the parent — the parent turn is still running while it awaits the Agent
tool result, and a parent ``idle`` triggers terminal sub-agent delivery.
Subagent transcripts live under a ``subagents/`` directory, which the
forwarder uses to skip them. We record a subagent ``Stop`` ahead of the
parent ``Stop``: the one and only idle POST must be the parent's.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
subagent_transcript = tmp_path / "session" / "subagents" / "agent-abc.jsonl"
subagent_transcript.parent.mkdir(parents=True, exist_ok=True)
subagent_transcript.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "parent-session",
"transcript_path": str(transcript_path),
},
)
# Subagent stops first — this must NOT surface as the parent going idle.
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "subagent-session",
"transcript_path": str(subagent_transcript),
},
)
# Parent turn ends — this SHOULD surface as the one idle edge.
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "parent-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Exactly one status POST: the parent's idle. The subagent ``Stop``
# (recorded first) must be skipped, so no second status POST arrives —
# the bounded wait below must time out.
first = await _get_recorded_request(server)
with pytest.raises(AssertionError):
await _get_recorded_request(server, timeout_s=0.5)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert first["body"] == {
"type": "external_session_status",
"data": {"status": "idle", "background_task_count": 0},
}
@pytest.mark.asyncio
async def test_forwarder_posts_compaction_in_progress_on_precompact_hook(
tmp_path: Path,
) -> None:
"""
Claude Code's ``PreCompact`` hook surfaces as ``in_progress``.
Claude compacts its own context in the terminal (manual ``/compact``
or automatic overflow); the Omnigent server never runs the compaction for
a claude-native session. Without forwarding ``PreCompact``, the web
UI gets no signal while Claude compacts — the gap the user reported
(the summary flushes in with no "Compacting…" spinner). The
forwarder maps it to ``external_compaction_status: in_progress`` so
Omnigent can publish the spinner SSE.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
# SessionStart (no source) populates transcript_path so the forwarder
# enters its loop; it is NOT a compaction edge and must NOT post.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "PreCompact", "session_id": "claude-session"},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# First POST is the compaction in_progress — the plain SessionStart
# before it produced no POST (it is not a compaction edge). If this
# were external_session_status or absent, the spinner would never
# appear for claude-native compaction.
assert request["path"] == "/v1/sessions/conv_abc/events"
assert request["body"] == {
"type": "external_compaction_status",
"data": {"status": "in_progress"},
}
@pytest.mark.asyncio
async def test_forwarder_posts_compaction_completed_on_compact_session_start(
tmp_path: Path,
) -> None:
"""
Post-compaction ``SessionStart source=compact`` surfaces as ``completed``.
Claude Code has no dedicated post-compaction hook; it resumes on the
freshly-compacted context with a ``SessionStart`` whose ``source`` is
``"compact"``. The forwarder maps exactly that source to
``external_compaction_status: completed`` so the web UI upgrades the
spinner to the permanent "Conversation compacted" marker. Other
SessionStart sources (startup/resume/clear) are not compaction and
must not post this.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
# Initial SessionStart enters the loop (not a compaction edge).
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
# Post-compaction SessionStart — the completion signal.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "compact",
"session_id": "claude-session",
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# First (and only) POST is compaction completed. If the source check
# regressed (e.g. firing for every SessionStart), startup/resume would
# spuriously emit completed and flicker the UI marker.
assert request["path"] == "/v1/sessions/conv_abc/events"
assert request["body"] == {
"type": "external_compaction_status",
"data": {"status": "completed"},
}
@pytest.mark.asyncio
async def test_forwarder_does_not_post_compaction_on_non_compact_session_start(
tmp_path: Path,
) -> None:
"""
A non-compact ``SessionStart`` (``source=startup``) emits no compaction.
Guards the source check specifically: only ``source == "compact"``
is the completion signal. A regression that fired on any
SessionStart — or used ``source is not None`` instead of
``== "compact"`` — would spuriously flash the "Conversation
compacted" marker on every startup/resume. We record a
``startup`` SessionStart followed by ``StopFailure``; because records
are processed in order, a spurious compaction POST would land BEFORE
the ``StopFailure`` → failed POST, so asserting the first POST is the
failed status proves the startup SessionStart emitted nothing.
(``StopFailure`` is used as the anchor because ``Stop`` no longer
posts a status — idle now comes from PTY pane activity.)
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "startup",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "StopFailure", "session_id": "claude-session"},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# The first POST is the StopFailure→failed status, NOT a compaction
# event: the preceding startup SessionStart produced nothing. If this
# body were external_compaction_status, the source check regressed.
assert request["path"] == "/v1/sessions/conv_abc/events"
assert request["body"] == {
"type": "external_session_status",
"data": {"status": "failed"},
}
class _CountingAuth(httpx.Auth):
"""
Test httpx Auth that mints a unique bearer per request.
Stamps ``Bearer token-<n>`` into ``Authorization`` where ``n`` is
the one-based call count. The counter is the observable that
proves the forwarder invokes the auth flow per outbound request
instead of capturing a single Authorization header at client
construction.
"""
def __init__(self) -> None:
"""
Initialize the auth with a zero call counter.
:returns: None.
"""
self.calls = 0
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
"""
Stamp a fresh ``Bearer token-<n>`` on every outgoing request.
:param request: Outgoing httpx request.
:yields: The request with a freshly minted ``Authorization``
header.
"""
self.calls += 1
request.headers["Authorization"] = f"Bearer token-{self.calls}"
yield request
@pytest.mark.asyncio
async def test_forwarder_uses_auth_to_refresh_token_per_request(tmp_path: Path) -> None:
"""
Each outbound HTTP request carries a freshly minted bearer token.
Regression test for the production bug where the forwarder
captured the bearer at startup and never refreshed it. After the
~1h Databricks OAuth token TTL, the stale token caused the
forwarder to spin in a permanent retry loop while the runner
kept processing turns — results never reached the UI. The fix
threads an ``httpx.Auth`` through the forwarder so the
Authorization header is recomputed on every request.
This test fails if the forwarder reverts to passing the bearer
as a static header on the ``AsyncClient`` (httpx snapshots
construction-time headers into ``client.headers`` and later
dict mutation does not propagate to in-flight requests).
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
# Two assistant transcript items → two external_conversation_item
# POSTs, which is all this test needs: distinct bearers on two
# outbound requests. We use transcript items rather than hook status
# because running/idle are no longer hook-derived (only
# StopFailure→failed remains, a single edge).
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "a1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "first"}],
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "a2",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "second"}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
# SessionStart sets transcript_path so the forwarder reads the
# transcript above; it posts no status of its own.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
auth = _CountingAuth()
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
auth=auth,
)
)
try:
# Two external_conversation_item POSTs (one per assistant item).
# The PATCH that mirrors the Claude session id is filtered out
# by ``_get_recorded_request``'s default ``method="POST"``.
first = await _get_recorded_request(server)
second = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# Each POST carried a non-empty bearer minted by the auth flow
# (matches ``Bearer token-<n>`` for some ``n``). The pattern check
# would fail with ``None`` if auth were not threaded into the
# AsyncClient at all.
assert first["authorization"] is not None and first["authorization"].startswith(
"Bearer token-"
), (
f"First POST must carry a bearer minted by the counting auth, "
f"got {first['authorization']!r}. ``None`` means auth was not "
f"threaded into httpx.AsyncClient."
)
assert second["authorization"] is not None and second["authorization"].startswith(
"Bearer token-"
), (
f"Second POST must carry a bearer minted by the counting auth, "
f"got {second['authorization']!r}."
)
# The load-bearing assertion: the two POSTs carry DIFFERENT
# bearers. If they were equal, httpx would be reusing a
# construction-time header snapshot instead of consulting the
# auth flow per request — that is exactly the production bug.
assert first["authorization"] != second["authorization"], (
f"Two consecutive POSTs share the same Authorization "
f"({first['authorization']!r}). The AsyncClient is reusing a "
f"snapshot of the original header instead of consulting auth "
f"on each request — this is the production token-refresh bug."
)
# Auth.auth_flow ran at least twice (one per recorded POST).
# The mirroring PATCH may add one more invocation; the lower
# bound is what matters — anything less means a request bypassed
# the auth path entirely.
assert auth.calls >= 2, (
f"Expected the counting auth to fire at least twice (one per item POST), got {auth.calls}."
)
@pytest.mark.asyncio
async def test_forwarder_posts_external_session_status_on_stop_failure_hook(
tmp_path: Path,
) -> None:
"""
``StopFailure`` maps to ``session.status`` failed, not idle.
A regression that collapses both Stop variants to ``idle`` would
silently hide turn errors from the web UI — the user would see
the session return to idle as if everything succeeded.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "StopFailure", "session_id": "claude-session"},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"] == {
"type": "external_session_status",
"data": {"status": "failed"},
}
@pytest.mark.asyncio
async def test_forwarder_start_at_end_uses_byte_offset_for_new_lines(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Reattach mode seeds from the last complete record and tails from there.
This catches the hot-path regression where ``start_at_end=True``
counted every old transcript line and subsequent polls rescanned
the whole file. The compatibility line-cursor reader is patched
to fail so the test proves the new byte-offset path is used.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
old_prefix = "".join(
json.dumps(
{
"type": "user",
"uuid": f"old-{index}",
"message": {"role": "user", "content": f"old {index}"},
}
)
+ "\n"
for index in range(100)
)
partial_record = (
'{"type":"assistant","uuid":"new-assistant","message":{"role":"assistant",'
'"content":[{"type":"text","text":"new only"}]}'
)
transcript_path.write_text(old_prefix + partial_record, encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
def _fail_line_cursor_reader(*args: object, **kwargs: object) -> None:
"""
Fail if start-at-end falls back to the full-file compatibility reader.
:param args: Positional reader arguments.
:param kwargs: Keyword reader arguments.
:returns: Never returns.
"""
del args, kwargs
raise AssertionError("start_at_end should seed and poll with byte offsets")
monkeypatch.setattr(
"omnigent.claude_native_forwarder.read_transcript_items_since_with_position",
_fail_line_cursor_reader,
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=True,
poll_interval_s=0.01,
)
)
try:
state = await _wait_for_json_file(bridge_dir / "transcript_forwarder.json")
assert state["byte_offset"] == len(old_prefix.encode("utf-8"))
with transcript_path.open("a", encoding="utf-8") as handle:
handle.write("}\n")
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["type"] == "external_conversation_item"
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "new only"}],
}
@pytest.mark.asyncio
async def test_forwarder_migrates_line_cursor_state_to_byte_offset(tmp_path: Path) -> None:
"""
Old transcript forwarder state gains a byte cursor after one poll.
Existing users can have ``transcript_forwarder.json`` files that
only contain ``line_cursor``. The first poll must preserve their
cursor semantics, forward only new records after that line, and
persist ``byte_offset`` so later polls avoid full-file rescans.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "old-user",
"message": {"role": "user", "content": "already forwarded"},
}
)
+ "\n"
+ json.dumps(
{
"type": "assistant",
"uuid": "new-assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "after old cursor"}],
},
}
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
(bridge_dir / "transcript_forwarder.json").write_text(
json.dumps(
{
"transcript_path": str(transcript_path),
"line_cursor": 1,
"current_response_id": None,
"seen_source_ids": [],
}
),
encoding="utf-8",
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_item_request(server)
state = await _wait_for_json_state(
bridge_dir / "transcript_forwarder.json",
lambda payload: payload.get("line_cursor") == 2 and "byte_offset" in payload,
)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["type"] == "external_conversation_item"
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "after old cursor"}],
}
assert state["line_cursor"] == 2
assert state["byte_offset"] == transcript_path.stat().st_size
@pytest.mark.asyncio
async def test_forwarder_waits_for_missing_fresh_transcript_without_warning(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A new conversation does not warn before Claude creates its transcript.
Claude hooks can advertise ``transcript_path`` before the JSONL file
exists. The forwarder should keep the fresh zero cursor, stay quiet
while the file is missing, and forward the first item once Claude
creates the file.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"transcript_path": str(transcript_path),
},
)
caplog.set_level(logging.WARNING, logger="omnigent.claude_native_forwarder")
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_fresh",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
state = await _wait_for_json_state(
bridge_dir / "transcript_forwarder.json",
lambda payload: (
payload.get("byte_offset") == 0 and "cursor_fingerprint" not in payload
),
)
assert state["line_cursor"] == 0
assert "cursor invalid" not in caplog.text
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "first-assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "first reply"}],
},
}
)
+ "\n",
encoding="utf-8",
)
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["type"] == "external_conversation_item"
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "first reply"}],
}
assert "cursor invalid" not in caplog.text
assert "cursor missing fingerprint" not in caplog.text
assert "cursor fingerprint changed" not in caplog.text
@pytest.mark.asyncio
async def test_measured_prefix_seed_keeps_a_prompt_injected_during_boot(
tmp_path: Path,
) -> None:
"""
Regression: a prompt Claude records while booting must still forward.
Cold resume writes the transcript prefix itself, then launches Claude. The
forwarder cannot seed until Claude's first hook advertises the transcript
path — and the executor's ``inject_user_message`` waits on the same boot,
so the paste routinely lands first. Seeding from a live end-offset then
puts the user's prompt BEHIND the cursor: visible in the TUI pane, absent
from the Omnigent DB, silently, for the session's lifetime.
Passing the prefix length measured before launch makes the skip exactly the
prefix, so the boot-window records survive however late the seed runs.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
# The synthesized prefix, complete before Claude starts.
transcript_path.write_text(
"".join(
json.dumps({"type": "user", "uuid": f"old{n}", "message": {"role": "user"}}) + "\n"
for n in range(3)
),
encoding="utf-8",
)
prefix_bytes = transcript_path.stat().st_size
# Claude boots and records the freshly-injected prompt before the forwarder
# is scheduled to seed.
with transcript_path.open("a", encoding="utf-8") as handle:
handle.write(
json.dumps(
{
"type": "user",
"uuid": "boot-window-prompt",
"message": {"role": "user", "content": "wake up and check the deploy"},
}
)
+ "\n"
)
state = await forwarder._ensure_state_for_transcript(
bridge_dir=bridge_dir,
state=None,
transcript_path=transcript_path,
start_at_end=True,
session_id="conv_boot_window",
start_at_offset=prefix_bytes,
)
# The measured prefix wins over ``start_at_end``: the cursor sits at the
# prefix boundary, not at EOF, so the prompt is still ahead of it.
assert state.byte_offset == prefix_bytes
result = forwarder._read_transcript_items_for_state(state, "claude-native-ui", None)
forwarded = [
block.get("text")
for item in result.items
for block in (item.data.get("content") or [])
if isinstance(block, dict)
]
assert "wake up and check the deploy" in forwarded
@pytest.mark.asyncio
async def test_measured_prefix_never_seeks_past_the_transcript_end(tmp_path: Path) -> None:
"""
A prefix length larger than the file clamps to the end.
Defensive: the measurement and the seed are separated by Claude's launch,
so a truncated or replaced transcript would otherwise leave the cursor
beyond EOF, where every later read looks like a stale-cursor reset.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps({"type": "user", "uuid": "only", "message": {"role": "user"}}) + "\n",
encoding="utf-8",
)
state = await forwarder._ensure_state_for_transcript(
bridge_dir=bridge_dir,
state=None,
transcript_path=transcript_path,
start_at_end=True,
session_id="conv_clamp",
start_at_offset=10**9,
)
assert state.byte_offset == transcript_path.stat().st_size
@pytest.mark.asyncio
async def test_forwarder_skips_to_end_on_stale_byte_cursor_state(tmp_path: Path) -> None:
"""
Stale byte-offset state skips to end of the replaced transcript.
A transcript path can be replaced or truncated between polls (e.g.
after Claude auto-compacts). The forwarder skips to the end of the
new file so existing content is not re-forwarded, then picks up
newly-appended records on subsequent polls.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
# Existing content that should NOT be re-forwarded after the skip.
existing_content = (
json.dumps(
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "compacted summary"}],
},
}
)
+ "\n"
)
transcript_path.write_text(existing_content, encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
(bridge_dir / "transcript_forwarder.json").write_text(
json.dumps(
{
"transcript_path": str(transcript_path),
"line_cursor": 25,
"byte_offset": 4096,
"cursor_fingerprint": "stale",
"current_response_id": "resp_old",
"seen_source_ids": ["byte-4096:25:message"],
}
),
encoding="utf-8",
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
expected_offset = len(existing_content.encode("utf-8"))
await _wait_for_json_state(
bridge_dir / "transcript_forwarder.json",
lambda payload: (
payload.get("byte_offset") == expected_offset
and isinstance(payload.get("cursor_fingerprint"), str)
),
)
# Drain any non-item requests (e.g. PATCH external_session_id).
item_posts = []
while not server.requests.empty():
req = server.requests.get_nowait()
if req.get("body", {}).get("type") == "external_conversation_item":
item_posts.append(req)
assert item_posts == [], (
"Forwarder should NOT have posted existing content after skip-to-end"
)
# Append a NEW record that should be forwarded.
new_record = (
json.dumps(
{
"type": "assistant",
"uuid": "new-after-compaction",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "new output"}],
},
}
)
+ "\n"
)
with transcript_path.open("a", encoding="utf-8") as f:
f.write(new_record)
# The new record should be forwarded.
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "new output"}],
}
@pytest.mark.asyncio
async def test_forwarder_skips_to_end_on_out_of_range_byte_cursor_without_fingerprint(
tmp_path: Path,
) -> None:
"""
A legacy byte cursor beyond EOF skips to the end of the truncated file.
Older state files can contain ``byte_offset`` without
``cursor_fingerprint``. If the transcript was truncated afterward
(e.g. compaction), the forwarder skips to the end of the new file
so existing content is not re-forwarded, then picks up new records.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
existing_content = (
json.dumps(
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "after truncation"}],
},
}
)
+ "\n"
)
transcript_path.write_text(existing_content, encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
(bridge_dir / "transcript_forwarder.json").write_text(
json.dumps(
{
"transcript_path": str(transcript_path),
"line_cursor": 25,
"byte_offset": 4096,
}
),
encoding="utf-8",
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
expected_offset = len(existing_content.encode("utf-8"))
await _wait_for_json_state(
bridge_dir / "transcript_forwarder.json",
lambda payload: (
payload.get("byte_offset") == expected_offset
and isinstance(payload.get("cursor_fingerprint"), str)
),
)
# Drain any non-item requests (e.g. PATCH external_session_id).
item_posts = []
while not server.requests.empty():
req = server.requests.get_nowait()
if req.get("body", {}).get("type") == "external_conversation_item":
item_posts.append(req)
assert item_posts == [], (
"Forwarder should NOT have posted existing content after skip-to-end"
)
# Append a new record — this one should be forwarded.
new_record = (
json.dumps(
{
"type": "assistant",
"uuid": "new-post-truncation",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "new output"}],
},
}
)
+ "\n"
)
with transcript_path.open("a", encoding="utf-8") as f:
f.write(new_record)
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "new output"}],
}
@pytest.mark.asyncio
async def test_forwarder_does_not_replay_after_compaction(tmp_path: Path) -> None:
"""
Regression test: compaction must not cause the forwarder to re-post
already-forwarded items.
Simulates the exact bug scenario: the forwarder has a valid cursor at
the end of the original transcript, then Claude compacts (rewrites the
file with new content and different UUIDs). The forwarder must skip to
the end of the compacted file without posting any of its content, then
forward only records appended after compaction.
Before the fix, the forwarder would reset to byte 0 and re-post every
record in the compacted file, causing the web UI to "replay" the entire
conversation history in real time.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
# Phase 1: write the "original" transcript and compute its fingerprint.
original_records = "".join(
json.dumps(
{
"type": "assistant",
"uuid": f"original-{i}",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": f"original message {i}"}],
},
}
)
+ "\n"
for i in range(5)
)
transcript_path.write_text(original_records, encoding="utf-8")
original_end = len(original_records.encode())
original_fingerprint = forwarder._jsonl_cursor_fingerprint(transcript_path, original_end)
# Phase 2: simulate compaction — replace the file with a summary that
# has DIFFERENT UUIDs (as Claude does during auto-compaction).
compacted_records = "".join(
json.dumps(
{
"type": "assistant",
"uuid": f"compacted-{i}",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": f"compacted summary {i}"}],
},
}
)
+ "\n"
for i in range(3)
)
transcript_path.write_text(compacted_records, encoding="utf-8")
compacted_end = len(compacted_records.encode())
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
# State file simulates a forwarder that had successfully forwarded the
# original transcript up to the end. The fingerprint will NOT match the
# compacted file — this is what triggers the skip-to-end behavior.
(bridge_dir / "transcript_forwarder.json").write_text(
json.dumps(
{
"transcript_path": str(transcript_path),
"line_cursor": 5,
"byte_offset": original_end,
"cursor_fingerprint": original_fingerprint,
"current_response_id": "resp_old_turn",
"seen_source_ids": [f"original-{i}:0:message" for i in range(5)],
}
),
encoding="utf-8",
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_compaction_test",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Wait until the forwarder has actually recovered the stale cursor.
# A fixed sleep is racy on slow CI: if the post-compaction append
# lands before validation runs, the stale-cursor recovery correctly
# skips to the then-current end and this test falsely reports that
# the fresh record was dropped.
await _wait_for_json_state(
bridge_dir / "transcript_forwarder.json",
lambda payload: payload.get("byte_offset") == compacted_end,
)
# Drain any non-item requests (e.g. PATCH external_session_id).
item_posts = []
while not server.requests.empty():
req = server.requests.get_nowait()
if req.get("body", {}).get("type") == "external_conversation_item":
item_posts.append(req)
assert item_posts == [], (
"Forwarder should NOT have posted compacted content. This is the "
"compaction replay bug — the forwarder re-posted items that "
"were already in the web UI."
)
# Phase 3: append a genuinely new record (Claude resuming work after
# compaction). This SHOULD be forwarded.
new_record = (
json.dumps(
{
"type": "assistant",
"uuid": "new-after-compaction",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "fresh output after compaction"}],
},
}
)
+ "\n"
)
with transcript_path.open("a", encoding="utf-8") as f:
f.write(new_record)
# The new record should be the only item forwarded (the turn also
# emits a leading running status edge, which this helper skips).
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# Only the post-compaction record was forwarded.
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "fresh output after compaction"}],
}
@pytest.mark.asyncio
async def test_forwarder_migrates_hook_cursor_state_to_byte_offset(tmp_path: Path) -> None:
"""
Old hook forwarder state gains a byte cursor after one status post.
Hook state migration must be per-record: a skipped ``SessionStart``
and a posted ``StopFailure`` should advance the durable byte offset
so the next poll does not rescan or repost either record.
(``StopFailure`` is the posted-status anchor because ``Stop`` no
longer maps to a status — idle now comes from PTY pane activity.)
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "StopFailure", "session_id": "claude-session"},
)
(bridge_dir / "hook_forwarder.json").write_text(
json.dumps({"event_cursor": 1}),
encoding="utf-8",
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
state = await _wait_for_json_state(
bridge_dir / "hook_forwarder.json",
lambda payload: payload.get("event_cursor") == 2 and "byte_offset" in payload,
)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"] == {
"type": "external_session_status",
"data": {"status": "failed"},
}
assert state["event_cursor"] == 2
assert state["byte_offset"] == (bridge_dir / "hooks.jsonl").stat().st_size
@pytest.mark.asyncio
async def test_forwarder_survives_unhandled_loop_exceptions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A non-HTTP loop exception is logged and the next poll continues.
This fails if a disk or parsing exception tears down the
background forwarder task, which leaves the browser mirror frozen
without surfacing a session event.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "survives-loop-error",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "after loop error"}],
},
}
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
original_forward = forwarder._forward_available_items
fail_once = True
async def _fail_once_forward_available_items(
**kwargs: Any,
) -> forwarder.TranscriptForwardState:
"""
Raise once, then delegate to the real forwarder.
:param kwargs: Keyword arguments passed by
:func:`forward_claude_transcript_to_session`.
:returns: Updated transcript forward state.
"""
nonlocal fail_once
if fail_once:
fail_once = False
raise PermissionError("state write failed")
return await original_forward(**kwargs)
monkeypatch.setattr(
forwarder,
"_forward_available_items",
_fail_once_forward_available_items,
)
caplog.set_level(logging.ERROR, logger="omnigent.claude_native_forwarder")
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_item_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert "Claude transcript forwarder loop failed" in caplog.text
assert request["body"]["type"] == "external_conversation_item"
assert request["body"]["data"]["item_data"] == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "after loop error"}],
}
@pytest.mark.asyncio
async def test_forwarder_drops_poison_item_after_bounded_permanent_retries(
tmp_path: Path,
) -> None:
"""
Permanent item rejections eventually advance the transcript cursor.
A malformed transcript item that Omnigent rejects with a permanent 4xx
should not be reposted forever at the poll interval. After the
retry budget is exhausted, the forwarder emits a failed status,
marks the source id handled, persists the new byte cursor, and
dead-letters the dropped item to disk so it is recoverable (#1120).
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "poison-item",
"message": {"role": "assistant", "content": "bad item"},
}
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker(
max_permanent_attempts=2,
base_delay_s=0.0,
max_delay_s=0.0,
)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Reject conversation items but accept failure status posts.
:param request: Outbound HTTP request from the forwarder.
:returns: HTTP response for the mock Omnigent endpoint.
"""
payload = json.loads(request.content.decode("utf-8"))
assert isinstance(payload, dict)
requests.append(payload)
if payload["type"] == "external_conversation_item":
return httpx.Response(422, json={"error": "bad item"})
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
dedupe = forwarder._ForwardDedupeState()
first = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
second = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=first,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
persisted = json.loads((bridge_dir / "transcript_forwarder.json").read_text("utf-8"))
# The poison item is attempted twice, then the forwarder-failed status. No
# status POST leads: the transcript path publishes none (Claude's status
# file owns the badge).
assert [request["type"] for request in requests] == [
"external_conversation_item",
"external_conversation_item",
"external_session_status",
]
# The failed edge carries BOTH the drop reason as ``output`` (#1113 — the
# server surfaces it as the failure detail) and the turn's response id so
# it closes the streaming turn instead of leaving its tool cards spinning.
assert requests[-1]["data"] == {
"status": "failed",
"output": "transcript item poison-item:0:message rejected",
"response_id": requests[0]["data"]["response_id"],
}
assert first.byte_offset == 0
assert second.byte_offset == transcript_path.stat().st_size
assert second.line_cursor == 1
assert second.seen_source_ids == ("poison-item:0:message",)
assert persisted["byte_offset"] == transcript_path.stat().st_size
assert persisted["seen_source_ids"] == ["poison-item:0:message"]
# The dropped item is dead-lettered to disk so it is recoverable
# instead of silently lost (#1120).
dead_letter = (bridge_dir / "dead_letter.jsonl").read_text("utf-8").splitlines()
assert len(dead_letter) == 1
record = json.loads(dead_letter[0])
assert record["session_id"] == "conv_abc"
assert record["event_type"] == "external_conversation_item"
assert record["reason"] == "permanent HTTP failure after retries"
assert record["payload"]["item_type"] == "message"
@pytest.mark.asyncio
async def test_forwarder_skips_user_item_on_ambiguous_post_failure(tmp_path: Path) -> None:
"""
An ambiguous POST failure skips the item instead of re-posting it.
A user message typed while Claude is busy round-trips through the
transcript and is POSTed as an ``external_conversation_item``. If
that POST's response is lost (e.g. a read timeout AFTER the server
appended the item and published ``session.input.consumed``), the
server has already committed it — and external items are not deduped
server-side. Retrying would append a second copy and re-publish the
consume event, producing a duplicate user bubble in the web UI.
The forwarder must instead treat the item as delivered:
mark it handled, advance the byte cursor, and never re-POST it.
A failure here (the item POSTed twice across two polls) is exactly
the duplicate-user-message regression this guards against.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "user-msg-1",
"message": {"role": "user", "content": "hello while busy"},
}
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker(base_delay_s=0.0, max_delay_s=0.0)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Record the POST, then fail the item POST with a read timeout.
The timeout stands in for "server committed, response lost" —
the ambiguous case where a blind retry duplicates.
:param request: Outbound HTTP request from the forwarder.
:returns: HTTP response (never reached for the item POST).
:raises httpx.ReadTimeout: For every ``external_conversation_item``
POST, simulating a lost response.
"""
payload = json.loads(request.content.decode("utf-8"))
assert isinstance(payload, dict)
requests.append(payload)
if payload["type"] == "external_conversation_item":
raise httpx.ReadTimeout("response lost", request=request)
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
dedupe = forwarder._ForwardDedupeState()
first = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
second = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=first,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
item_posts = [r for r in requests if r["type"] == "external_conversation_item"]
# The item was POSTed exactly once. If the ambiguous-failure skip
# were missing, the second poll would re-read offset 0 and POST it
# again (len 2) — the duplicate user bubble.
assert len(item_posts) == 1
# No "failed" status: unlike a permanent 4xx rejection, an ambiguous
# failure most likely succeeded, so we must not flag the turn failed.
assert all(r["type"] != "external_session_status" for r in requests)
# Cursor advanced past the item and it is recorded as handled, so it
# is not re-read on subsequent polls.
assert first.byte_offset == transcript_path.stat().st_size
assert first.seen_source_ids == ("user-msg-1:0:message",)
assert second.byte_offset == transcript_path.stat().st_size
@pytest.mark.asyncio
async def test_forwarder_retries_user_item_on_connect_error(tmp_path: Path) -> None:
"""
A provably-undelivered POST failure is retried, not dropped.
A connection-refused error proves the request never reached the
server, so the item was not committed. Dropping it would silently
lose a user message. The forwarder must hold the cursor and re-POST
on the next poll — the complement to the ambiguous-skip behavior, so
the duplicate fix does not turn into a message-loss bug.
A failure here (item marked handled / cursor advanced after a
connect error) would mean a user message is silently lost whenever
the server is briefly unreachable.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "user-msg-2",
"message": {"role": "user", "content": "server is down"},
}
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker(base_delay_s=0.0, max_delay_s=0.0)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Fail every item POST with a connection error (never delivered).
:param request: Outbound HTTP request from the forwarder.
:returns: HTTP response (never reached for the item POST).
:raises httpx.ConnectError: For every ``external_conversation_item``
POST, simulating an unreachable server.
"""
payload = json.loads(request.content.decode("utf-8"))
assert isinstance(payload, dict)
requests.append(payload)
if payload["type"] == "external_conversation_item":
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
dedupe = forwarder._ForwardDedupeState()
first = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
second = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=first,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
item_posts = [r for r in requests if r["type"] == "external_conversation_item"]
# Re-POSTed on the second poll (2 attempts): a connect error proves
# non-delivery, so the item must be retried, not skipped.
assert len(item_posts) == 2
# Cursor held at the start and the item never marked handled, so it
# keeps being retried until it lands.
assert first.byte_offset == 0
assert first.seen_source_ids == ()
assert second.byte_offset == 0
@pytest.mark.asyncio
async def test_forwarder_state_writes_run_off_event_loop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Cursor state persistence uses a worker thread for fsync writes.
This catches regressions where the async forwarder calls the
sync atomic writer directly and blocks the event loop for every
transcript item.
"""
main_thread_id = threading.get_ident()
writer_thread_ids: list[int] = []
original_write = forwarder._write_json_atomic
def _recording_write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
"""
Record the thread used for the atomic JSON write.
:param path: Destination JSON path.
:param payload: JSON payload to write.
:returns: None.
"""
writer_thread_ids.append(threading.get_ident())
original_write(path, payload)
monkeypatch.setattr(forwarder, "_write_json_atomic", _recording_write_json_atomic)
await forwarder._write_forward_state_async(
tmp_path / "bridge",
forwarder.TranscriptForwardState(
transcript_path=tmp_path / "session.jsonl",
line_cursor=0,
byte_offset=0,
cursor_fingerprint="fingerprint",
),
)
assert writer_thread_ids
assert all(thread_id != main_thread_id for thread_id in writer_thread_ids)
@pytest.mark.asyncio
async def test_forwarder_mirrors_external_session_id_after_hook_event(
tmp_path: Path,
) -> None:
"""
Forwarder PATCHes the Omnigent conversation with Claude's session id.
After the bridge records a hook event carrying ``session_id``
(every hook from Claude does), the forwarder's first loop pass
PATCHes ``/v1/sessions/{id}`` with the captured value as
``external_session_id``. This is the mirror PR 2's resume flow
depends on — without it, cold-resume has no way to recover the
claude-side session that the bridge captured locally.
"""
bridge_dir = tmp_path / "bridge"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "a1b2c3d4-1234-5678-9abc-def012345678",
"transcript_path": str(tmp_path / "session.jsonl"),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
patch_request = await _get_recorded_request(server, method="PATCH")
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# Path proves the PATCH targets the right session — a bug that
# PATCHed e.g. the agent record would route here with a
# different prefix.
assert patch_request["path"] == "/v1/sessions/conv_abc"
# Body asserts the captured Claude id flowed through unchanged.
# If the bridge state read returned the wrong key or the
# request body construction dropped the field, the assertion
# against the literal uuid catches it.
assert patch_request["body"] == {
"external_session_id": "a1b2c3d4-1234-5678-9abc-def012345678",
}
@pytest.mark.asyncio
async def test_forwarder_mirrors_external_session_id_at_most_once(
tmp_path: Path,
) -> None:
"""
The mirror PATCH is one-shot per forwarder process.
The forwarder loop polls every ``poll_interval_s``. Without the
in-process latch the bridge state file still says
``claude_session_id=...`` on every tick, so the loop would
PATCH on every iteration — hammering the server and racing the
store's overwrite-protection on every poll. This test pumps the
loop through multiple iterations (transcript posts) and asserts
no second PATCH ever arrives.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
# Two assistant messages so the loop has work to do across at
# least two ticks (the existing forwarder tests show this is
# plenty for the loop to run multiple poll iterations).
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "assistant-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "first"}],
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-2",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "second"}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
# ``StopFailure`` (not ``Stop``) so the hook still produces one status
# POST — ``Stop`` no longer maps to a status (idle comes from PTY pane
# activity). Its ``session_id`` is what the mirror PATCH latches onto;
# the failed status is the third POST the loop-pump below consumes.
record_hook_event(
bridge_dir,
{
"hook_event_name": "StopFailure",
"session_id": "claude-sid-once",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_once",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# The first PATCH MUST land — covered by the previous test;
# consume it so it doesn't pollute the residual-queue check.
first_patch = await _get_recorded_request(server, method="PATCH")
assert first_patch["body"]["external_session_id"] == "claude-sid-once"
# Pump several POST requests through the loop — proves the
# loop ran multiple iterations after the first PATCH. The
# bridge state still carries claude_session_id; the only
# reason no second PATCH arrives is the in-process latch.
for _ in range(3):
await _get_recorded_request(server, method="POST")
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# Drain whatever is left in the queue and assert no PATCH
# snuck in. (queue.empty() with FIFO + post-cancel teardown
# gives a consistent snapshot.)
leftover_patches: list[dict[str, Any]] = []
while not server.requests.empty():
item = server.requests.get_nowait()
if item.get("method") == "PATCH":
leftover_patches.append(item)
# If the latch broke, multiple PATCH requests would accumulate
# across the ~3 iterations we forced. Asserting on the literal
# list (not just length) gives a useful diff in failure output.
assert leftover_patches == []
@pytest.mark.asyncio
async def test_forwarder_does_not_mirror_when_hook_payload_lacks_session_id(
tmp_path: Path,
) -> None:
"""
No PATCH when the bridge has not captured a Claude session id.
If the hook payload arrives without ``session_id`` (or the
first poll happens before any hook record exists), the bridge
state file has no ``claude_session_id`` field and the forwarder
has nothing to mirror. The PATCH must not fire — otherwise we'd
send a null/empty external_session_id and the route would 400.
"""
bridge_dir = tmp_path / "bridge"
# Hook event WITHOUT session_id so bridge state's
# ``claude_session_id`` stays unset. The transcript_path field
# is still present so the rest of the loop has something to
# poll.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"transcript_path": str(tmp_path / "session.jsonl"),
},
)
# Empty transcript file — the loop runs but produces no
# transcript posts either.
(tmp_path / "session.jsonl").write_text("", encoding="utf-8")
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_nopatch",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
# Let the loop run a few poll cycles. We can't await a request
# since none should arrive, so sleep just long enough for the
# loop to have iterated several times — well above the
# 10 ms poll interval, well below any reasonable test budget.
await asyncio.sleep(0.2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
drained: list[dict[str, Any]] = []
while not server.requests.empty():
drained.append(server.requests.get_nowait())
methods = [request["method"] for request in drained]
# If the forwarder sent ANY PATCH despite missing
# claude_session_id, the bridge-state-read short-circuit broke
# — a regression that would route empty/null
# external_session_id values to the server.
assert "PATCH" not in methods, f"unexpected PATCH(es): {drained}"
@pytest.mark.asyncio
async def test_forward_model_from_status_posts_the_status_model_verbatim(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The statusLine's model posts VERBATIM — the harness's own spelling,
never collapsed to a picker alias — and dedupes on repeat polls.
A family collapse here is how a routed Opus 4.9 rendered as the
``opus`` row holding 4.8; the verbatim report is what makes the web's
exact-match highlight truthful for every generation and provider
spelling.
"""
monkeypatch.setattr(
forwarder,
"read_claude_context_state",
lambda _bridge_dir: {"model": "databricks-claude-opus-4-9", "context_window_size": 200000},
)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
dedupe = forwarder._ForwardDedupeState()
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
await forwarder._forward_model_from_status(
client, session_id="conv_abc", bridge_dir=tmp_path, dedupe=dedupe
)
await forwarder._forward_model_from_status(
client, session_id="conv_abc", bridge_dir=tmp_path, dedupe=dedupe
)
model_posts = [r for r in requests if r["type"] == "external_model_change"]
assert model_posts == [
{"type": "external_model_change", "data": {"model": "databricks-claude-opus-4-9"}}
]
assert dedupe.posted_model == "databricks-claude-opus-4-9"
@pytest.mark.asyncio
async def test_model_reports_keep_generation_and_context_marker(tmp_path: Path) -> None:
"""
Reports preserve the generation and the ``[1m]`` marker byte-for-byte.
Two same-family models of different generations (a routed 4.9 beside a
pinned 4.8) and a 1M-context variant must each post as themselves —
any normalization would let the record claim a model the pane is not
on.
"""
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
dedupe = forwarder._ForwardDedupeState()
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for model in (
"databricks-claude-opus-4-8",
"databricks-claude-opus-4-9",
"databricks-claude-opus-4-9[1m]",
):
await forwarder._post_model_change_if_new(
client, session_id="conv_abc", dedupe=dedupe, model=model
)
assert [r["data"]["model"] for r in requests] == [
"databricks-claude-opus-4-8",
"databricks-claude-opus-4-9",
"databricks-claude-opus-4-9[1m]",
]
@pytest.mark.asyncio
async def test_forwarder_reports_the_launch_model_then_a_switch(tmp_path: Path) -> None:
"""
EVERY observation posts, verbatim: the first is the launch report.
The first assistant entry names the model the session spawned on —
posting it is what seeds ``reported_model`` so surfaces show the
pane's truth within seconds of launch — and a later assistant entry
on a different model posts that new model, byte-for-byte.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
def _assistant(uuid: str, model: str, text: str) -> str:
"""
Build one assistant JSONL line carrying ``message.model``.
:param uuid: Transcript entry uuid, e.g. ``"a1"``.
:param model: Concrete model id, e.g. ``"claude-opus-4-8"``.
:param text: Assistant text content.
:returns: A JSON-encoded transcript line.
"""
return json.dumps(
{
"type": "assistant",
"uuid": uuid,
"message": {
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": text}],
},
}
)
transcript_path.write_text(_assistant("a1", "claude-opus-4-8", "hi") + "\n", encoding="utf-8")
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker()
dedupe = forwarder._ForwardDedupeState()
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Accept every forwarder POST and record its payload.
:param request: Outbound HTTP request from the forwarder.
:returns: 202 for every event.
"""
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
state = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
# The first observation IS the launch report — posted verbatim.
launch_posts = [r for r in requests if r["type"] == "external_model_change"]
assert [p["data"] for p in launch_posts] == [{"model": "claude-opus-4-8"}]
assert dedupe.posted_model == "claude-opus-4-8"
# User switches model inside the terminal.
with transcript_path.open("a", encoding="utf-8") as fh:
fh.write(_assistant("a2", "claude-sonnet-5", "switched") + "\n")
requests.clear()
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
model_posts = [r for r in requests if r["type"] == "external_model_change"]
assert len(model_posts) == 1
assert model_posts[0]["data"] == {"model": "claude-sonnet-5"}
assert dedupe.posted_model == "claude-sonnet-5"
@pytest.mark.asyncio
async def test_forwarder_mirrors_tui_rename_on_first_observation(tmp_path: Path) -> None:
"""
A ``/rename`` posts ``external_session_title`` on the FIRST observation.
Unlike the model mirror there is no spawn default to protect: a
``custom-title`` record exists only because the operator renamed the
session, so it is a real change worth posting immediately.
The second phase rewinds the byte cursor so the same ``custom-title``
record is read again — the restart / rewind path the dedupe exists
for. A steady-state poll reads only past its cursor and would never
re-see the record, so rewinding is what actually exercises the guard.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps({"type": "custom-title", "customTitle": "auth-refactor", "sessionId": "s1"})
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker()
dedupe = forwarder._ForwardDedupeState()
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Accept every forwarder POST and record its payload.
:param request: Outbound HTTP request from the forwarder.
:returns: 202 for every event.
"""
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
state = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
title_posts = [r for r in requests if r["type"] == "external_session_title"]
assert len(title_posts) == 1
assert title_posts[0]["data"] == {"title": "auth-refactor"}
assert dedupe.posted_title == "auth-refactor"
# Rewind to the top of the file so the rename record is re-read,
# as a restart / cursor rewind would. The dedupe must swallow it.
requests.clear()
rewound = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=rewound,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
assert [r for r in requests if r["type"] == "external_session_title"] == []
@pytest.mark.asyncio
async def test_forwarder_retries_title_post_after_transient_failure(tmp_path: Path) -> None:
"""
A failed ``external_session_title`` POST is retried on a later poll.
``observed_title`` is sticky across polls, so a poll whose incremental
window carries no ``custom-title`` record still reconciles the observed
title against the last POSTed one — the rename is not lost once the
original poll's window is gone.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps({"type": "custom-title", "customTitle": "auth-refactor", "sessionId": "s1"})
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker()
dedupe = forwarder._ForwardDedupeState()
fail_titles = True
title_posts: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Fail title posts while ``fail_titles`` is set; accept everything else.
:param request: Outbound HTTP request from the forwarder.
:returns: 500 for the first title post, else 202.
"""
payload = json.loads(request.content.decode("utf-8"))
if payload["type"] == "external_session_title":
title_posts.append(payload)
if fail_titles:
return httpx.Response(500, json={})
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
state = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
# The POST failed, so the baseline stays behind the observation.
assert len(title_posts) == 1
assert dedupe.observed_title == "auth-refactor"
assert dedupe.posted_title is None
# Next poll: no new rename in the window, but the retry still fires.
fail_titles = False
with transcript_path.open("a", encoding="utf-8") as fh:
fh.write(
json.dumps(
{"type": "user", "uuid": "u1", "message": {"role": "user", "content": "hi"}}
)
+ "\n"
)
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
assert len(title_posts) == 2
assert dedupe.posted_title == "auth-refactor"
@pytest.mark.asyncio
async def test_forwarder_retries_model_post_after_transient_failure(tmp_path: Path) -> None:
"""
A failed ``external_model_change`` POST is retried on a later poll —
not lost once the switch poll's transcript window is gone.
``observed_model`` is sticky across polls, so even a poll whose
incremental window carries no fresh ``message.model`` (e.g. a plain
user turn) reconciles the observed alias against the last POSTed one
and re-attempts the drop. Guards the self-healing contract of the
model mirror against a single transient Omnigent error.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
def _assistant(uuid: str, model: str) -> str:
"""Build an assistant JSONL line carrying ``message.model``."""
return json.dumps(
{
"type": "assistant",
"uuid": uuid,
"message": {
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": "x"}],
},
}
)
def _user(uuid: str) -> str:
"""Build a user JSONL line (no ``message.model``)."""
return json.dumps(
{"type": "user", "uuid": uuid, "message": {"role": "user", "content": "thanks"}}
)
transcript_path.write_text(_assistant("a1", "claude-opus-4-8") + "\n", encoding="utf-8")
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker()
dedupe = forwarder._ForwardDedupeState()
model_posts: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""Fail the FIRST external_model_change POST (503); accept the rest."""
body = json.loads(request.content.decode("utf-8"))
if body["type"] == "external_model_change":
model_posts.append(body["data"])
if len(model_posts) == 1:
return httpx.Response(503, json={"error": "transient"})
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async def _poll() -> None:
nonlocal state
state = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
# Poll 1: the launch report is attempted and fails transiently.
await _poll()
assert model_posts == [{"model": "claude-opus-4-8"}]
assert dedupe.posted_model is None # NOT advanced — POST failed
assert dedupe.observed_model == "claude-opus-4-8" # but remembered
# Poll 2: a plain user turn (no message.model) still retries the drop.
with transcript_path.open("a", encoding="utf-8") as fh:
fh.write(_user("u1") + "\n")
await _poll()
assert model_posts == [{"model": "claude-opus-4-8"}, {"model": "claude-opus-4-8"}]
assert dedupe.posted_model == "claude-opus-4-8" # now committed
# Poll 3: a TUI switch posts the new model verbatim.
with transcript_path.open("a", encoding="utf-8") as fh:
fh.write(_assistant("a2", "claude-sonnet-5") + "\n")
await _poll()
assert model_posts[-1] == {"model": "claude-sonnet-5"}
assert dedupe.posted_model == "claude-sonnet-5"
@pytest.mark.asyncio
async def test_forwarder_mirrors_in_pane_permission_mode_switch(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Both the launch mode and a later shift+tab reach the session label.
Claude Code emits no event on a mode change, so the pane footer is polled.
The launch mode is posted as well: a manual-mode session has no mode label
and no launch flag, so skipping it would leave the web picker with nothing
to render. Repeat polls of an unchanged footer stay quiet.
"""
bridge_dir = tmp_path / "bridge"
pane_mode: str | None = "default"
reads = 0
def _fake_read(_bridge_dir: Path) -> str | None:
"""Serve the pane's current mode, counting each capture."""
nonlocal reads
reads += 1
return pane_mode
monkeypatch.setattr(forwarder, "read_permission_mode", _fake_read)
# Reads are throttled off a monotonic deadline; zero the interval so each
# call in this test performs a capture instead of returning early.
monkeypatch.setattr(forwarder, "_PERMISSION_MODE_POLL_INTERVAL_S", 0.0)
dedupe = forwarder._ForwardDedupeState()
posts: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""Accept every POST and record its payload."""
posts.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async def _poll() -> None:
"""Run one permission-mode mirror pass."""
await forwarder._forward_permission_mode_from_pane(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
dedupe=dedupe,
)
# Poll 1: the launch mode is posted, so the picker has a mode to show.
await _poll()
assert [p["type"] for p in posts] == ["external_permission_mode_change"]
assert posts[0]["data"] == {"permission_mode": "default"}
assert dedupe.posted_permission_mode == "default"
# Poll 2: unchanged footer is a no-op, not a repeat POST.
await _poll()
assert len(posts) == 1
# Poll 3: the user presses shift+tab into auto mode.
pane_mode = "auto"
await _poll()
assert posts[-1]["data"] == {"permission_mode": "auto"}
assert dedupe.posted_permission_mode == "auto"
# Poll 4: still auto — the switch isn't re-posted every poll.
await _poll()
assert len(posts) == 2
# A footerless pane reads as unknown and must not post a reversal.
pane_mode = None
await _poll()
assert len(posts) == 2
assert dedupe.posted_permission_mode == "auto"
assert reads == 5
@pytest.mark.asyncio
async def test_forwarder_posts_manual_launch_mode_so_picker_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A manual-mode launch publishes a mode, keeping the picker reachable.
Manual is the default, and launching into it writes no
``--permission-mode`` arg and no mode label, leaving the pane footer as the
only source. The first poll must post it: with no mode stored the web
picker hides itself, and manual becomes a state no one can switch out of.
"""
bridge_dir = tmp_path / "bridge"
monkeypatch.setattr(forwarder, "read_permission_mode", lambda _bridge_dir: "default")
monkeypatch.setattr(forwarder, "_PERMISSION_MODE_POLL_INTERVAL_S", 0.0)
dedupe = forwarder._ForwardDedupeState()
posts: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""Accept the POST and record its payload."""
posts.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
await forwarder._forward_permission_mode_from_pane(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
dedupe=dedupe,
)
assert posts == [
{"type": "external_permission_mode_change", "data": {"permission_mode": "default"}}
]
assert dedupe.posted_permission_mode == "default"
@pytest.mark.asyncio
async def test_forwarder_retries_permission_mode_post_after_transient_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A failed mode POST is retried, so the switch isn't silently dropped.
The pane is the source of truth but the poll window moves on; if a 503
advanced the baseline, the web picker would stay stale until the user
switched modes again.
"""
pane_mode = "default"
def _fake_read(_bridge_dir: Path) -> str | None:
"""Serve the pane's current mode."""
return pane_mode
monkeypatch.setattr(forwarder, "read_permission_mode", _fake_read)
monkeypatch.setattr(forwarder, "_PERMISSION_MODE_POLL_INTERVAL_S", 0.0)
dedupe = forwarder._ForwardDedupeState()
posts: list[dict[str, Any]] = []
fail_modes = {"plan"}
def _handle_request(request: httpx.Request) -> httpx.Response:
"""Fail the first ``plan`` POST with 503; accept everything else."""
payload = json.loads(request.content.decode("utf-8"))
posts.append(payload)
mode = payload["data"]["permission_mode"]
if mode in fail_modes:
fail_modes.discard(mode)
return httpx.Response(503, json={})
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async def _poll() -> None:
"""Run one permission-mode mirror pass."""
await forwarder._forward_permission_mode_from_pane(
client=client,
session_id="conv_abc",
bridge_dir=tmp_path / "bridge",
dedupe=dedupe,
)
await _poll() # the launch mode lands
assert dedupe.posted_permission_mode == "default"
pane_mode = "plan"
await _poll()
assert len(posts) == 2
assert dedupe.posted_permission_mode == "default" # NOT advanced — POST failed
await _poll()
assert [p["data"] for p in posts] == [
{"permission_mode": "default"},
{"permission_mode": "plan"},
{"permission_mode": "plan"},
]
assert dedupe.posted_permission_mode == "plan" # now committed
@pytest.mark.asyncio
async def test_forwarder_throttles_permission_mode_pane_reads(tmp_path: Path) -> None:
"""
Pane reads are spaced by the throttle, not run on every poll.
Unlike the file-backed model mirror sharing this loop, each read spawns a
``tmux capture-pane`` subprocess. The poll loop is far tighter than the
throttle, so an unthrottled read would spawn processes continuously for a
signal that only changes when a human presses shift+tab.
"""
reads = 0
def _fake_read(_bridge_dir: Path) -> str | None:
"""Count captures; the mode itself is irrelevant here."""
nonlocal reads
reads += 1
return "default"
with patch.object(forwarder, "read_permission_mode", _fake_read):
dedupe = forwarder._ForwardDedupeState()
transport = httpx.MockTransport(lambda _req: httpx.Response(202, json={}))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for _ in range(5):
await forwarder._forward_permission_mode_from_pane(
client=client,
session_id="conv_abc",
bridge_dir=tmp_path / "bridge",
dedupe=dedupe,
)
# Five back-to-back polls inside one throttle window capture once.
assert reads == 1
assert dedupe.permission_mode_next_read > 0.0
def test_validated_transcript_state_resets_legacy_byte_cursor_without_fingerprint(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Byte-offset state without a fingerprint is treated as stale.
A replaced transcript cannot be validated from the byte cursor
alone. The forwarder skips to the end of the transcript to avoid
re-posting content that was already forwarded.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "replacement",
"message": {"role": "assistant", "content": "new file"},
}
)
+ "\n",
encoding="utf-8",
)
caplog.set_level(logging.WARNING, logger="omnigent.claude_native_forwarder")
validated = forwarder._validated_transcript_state(
forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=25,
byte_offset=0,
current_response_id="resp_old",
seen_source_ids=("old-source",),
cursor_fingerprint=None,
),
bridge_dir=tmp_path / "bridge",
session_id="conv_abc",
)
# Cursor skips to end of transcript but preserves seen_source_ids to
# prevent re-posting items that were already forwarded before the reset.
expected_end = transcript_path.stat().st_size
assert validated == forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=expected_end,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, expected_end),
seen_source_ids=("old-source",),
)
assert "cursor missing fingerprint" in caplog.text
assert "conv_abc" in caplog.text
assert str(tmp_path / "bridge") in caplog.text
def test_validated_transcript_state_adopts_fingerprint_at_offset_zero_without_reset(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Fresh state at byte_offset=0 with no fingerprint adopts the computed
fingerprint without resetting seen_source_ids.
This is the typical case when the forwarder initializes before the
transcript file exists (fingerprint is None because the file is
missing), and the file appears later. Since line_cursor is 0 (nothing
has been read yet), there is no stale position — just adopt the
fingerprint and keep going.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "first-entry",
"message": {"role": "assistant", "content": "hello"},
}
)
+ "\n",
encoding="utf-8",
)
caplog.set_level(logging.WARNING, logger="omnigent.claude_native_forwarder")
pre_existing_seen = ("already-sent-id-1", "already-sent-id-2")
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
current_response_id="resp_in_flight",
seen_source_ids=pre_existing_seen,
cursor_fingerprint=None,
)
validated = forwarder._validated_transcript_state(
state,
bridge_dir=tmp_path / "bridge",
session_id="conv_fresh",
)
expected_fingerprint = forwarder._jsonl_cursor_fingerprint(transcript_path, 0)
# Fingerprint adopted from the now-existing file, not reset to a blank
# state. If the fingerprint is None here, the file doesn't exist (test
# setup bug).
assert validated.cursor_fingerprint == expected_fingerprint
assert validated.cursor_fingerprint is not None
# seen_source_ids preserved — this is the critical fix. Without it, the
# forwarder would re-read the entire transcript and re-post every item.
assert validated.seen_source_ids == pre_existing_seen, (
f"Expected seen_source_ids to be preserved across fingerprint adoption, "
f"but got {validated.seen_source_ids!r}. If empty, the dedup set was "
f"cleared and the forwarder will re-post already-delivered items."
)
# Other state fields preserved (not zeroed out).
assert validated.line_cursor == 0
assert validated.byte_offset == 0
assert validated.current_response_id == "resp_in_flight"
# No warning logged — this is a clean adoption, not a stale-cursor reset.
assert "cursor missing fingerprint" not in caplog.text
assert "cursor invalid" not in caplog.text
assert "cursor fingerprint changed" not in caplog.text
def test_validated_transcript_state_preserves_seen_source_ids_on_stale_reset(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
When a cursor reset IS needed (fingerprint changed because the file was
replaced), seen_source_ids is still preserved to prevent duplicate posts.
The cursor skips to the end of the replacement file so its existing
content is not re-forwarded, but the dedup set keeps IDs of items
already forwarded to the server as a safety net.
"""
transcript_path = tmp_path / "session.jsonl"
original_content = (
json.dumps(
{
"type": "assistant",
"uuid": "original",
"message": {"role": "assistant", "content": "original"},
}
)
+ "\n"
)
transcript_path.write_text(original_content, encoding="utf-8")
original_fingerprint = forwarder._jsonl_cursor_fingerprint(
transcript_path, len(original_content.encode())
)
# Replace the file content — fingerprint at the old offset will differ.
replacement_content = (
json.dumps(
{
"type": "assistant",
"uuid": "replacement",
"message": {"role": "assistant", "content": "replaced"},
}
)
+ "\n"
)
transcript_path.write_text(replacement_content, encoding="utf-8")
caplog.set_level(logging.WARNING, logger="omnigent.claude_native_forwarder")
pre_existing_seen = ("item-a", "item-b", "item-c")
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=5,
byte_offset=len(original_content.encode()),
current_response_id="resp_old",
seen_source_ids=pre_existing_seen,
cursor_fingerprint=original_fingerprint,
)
validated = forwarder._validated_transcript_state(
state,
bridge_dir=tmp_path / "bridge",
session_id="conv_replaced",
)
# Cursor skips to end of replacement file (avoids re-posting its content).
assert validated.line_cursor == 0
expected_end = len(replacement_content.encode())
assert validated.byte_offset == expected_end
# seen_source_ids preserved despite cursor reset — the critical fix.
# Without this, every item from the replacement file would be posted
# as new, even if some source IDs overlap with already-forwarded items.
assert validated.seen_source_ids == pre_existing_seen, (
f"Expected seen_source_ids to survive cursor reset, but got "
f"{validated.seen_source_ids!r}. If empty, the dedup safety net "
f"was destroyed and duplicates will be posted."
)
# Warning logged because the fingerprint genuinely changed.
assert "cursor fingerprint changed" in caplog.text
# ── supervise_forwarder ────────────────────────────────────────────
def _supervisor_kwargs(tmp_path: Path) -> dict[str, Any]:
"""
Build the kwargs used to invoke :func:`supervise_forwarder` in tests.
The supervisor passes these through to the (stubbed) forwarder
coroutine; nothing here has to be a real running service since
every test patches the forwarder.
:param tmp_path: Pytest-provided temp directory used as the
bridge dir argument.
:returns: Dict of keyword arguments suitable for
``supervise_forwarder(**kwargs)``.
"""
return {
"base_url": "http://localhost:0",
"headers": {},
"session_id": "conv_abc",
"bridge_dir": tmp_path,
"agent_name": "claude",
"start_at_end": False,
}
@pytest.mark.asyncio
async def test_supervise_forwarder_restarts_after_crash(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A non-cancellation exception in the forwarder restarts it.
This is the case that left the chat view permanently desynced
overnight: the forwarder task died inside its own
``async with httpx.AsyncClient`` block, the parent's
``await _attach_with_reconnect`` kept running, and no one
restarted the forwarder. With the supervisor we expect a second
call after the first one raises.
"""
call_count = 0
async def fake_forwarder(**_: Any) -> None:
"""Fake forwarder: crash on call 1, signal stop on call 2."""
nonlocal call_count
call_count += 1
if call_count == 1:
# RuntimeError stands in for the kinds of errors that can
# escape the forwarder's inner ``except Exception`` (e.g.
# something raised during the ``async with`` setup before
# the per-iteration try block). The supervisor catches
# Exception and restarts.
raise RuntimeError("simulated unrecoverable crash")
# CancelledError is the ONLY thing the supervisor re-raises,
# so use it as the test's exit signal once we've verified
# the restart happened.
raise asyncio.CancelledError()
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
"""Record sleeps without waiting."""
sleeps.append(seconds)
monkeypatch.setattr(forwarder, "forward_claude_transcript_to_session", fake_forwarder)
monkeypatch.setattr(forwarder, "_supervisor_sleep", fake_sleep)
with caplog.at_level(logging.WARNING, logger=forwarder.__name__):
with pytest.raises(asyncio.CancelledError):
await forwarder.supervise_forwarder(**_supervisor_kwargs(tmp_path))
# 2 = first crash + restart. If the supervisor exited after the
# first crash (the pre-fix behavior), call_count would be 1.
assert call_count == 2, (
f"Forwarder should have been called twice (initial + restart), "
f"got {call_count}. If 1, the supervisor exited on crash "
f"instead of restarting."
)
# One sleep ran — between the crash and the restart. The second
# call raised CancelledError, which propagates immediately and
# skips the post-iteration sleep.
assert sleeps == [forwarder._SUPERVISOR_INITIAL_BACKOFF_S]
assert "Claude transcript forwarder crashed" in caplog.text
@pytest.mark.asyncio
async def test_supervise_forwarder_propagates_cancellation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
:class:`asyncio.CancelledError` exits the supervisor without restarting.
The parent's ``finally`` block relies on this: ``forwarder.cancel()``
followed by ``await forwarder`` must complete promptly with a
single CancelledError, not loop forever on restart.
"""
call_count = 0
forwarder_running = asyncio.Event()
async def fake_forwarder(**_: Any) -> None:
"""Fake forwarder that announces it's running and then blocks."""
nonlocal call_count
call_count += 1
forwarder_running.set()
# Wait forever — let the test cancel us.
await asyncio.Event().wait()
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
"""Record sleeps; cancellation must NOT route through here."""
sleeps.append(seconds)
monkeypatch.setattr(forwarder, "forward_claude_transcript_to_session", fake_forwarder)
monkeypatch.setattr(forwarder, "_supervisor_sleep", fake_sleep)
supervisor_task = asyncio.create_task(
forwarder.supervise_forwarder(**_supervisor_kwargs(tmp_path)),
)
# Wait until the fake forwarder is actually executing before
# cancelling, so the cancellation hits inside the forwarder
# call (the realistic path), not before it even starts.
await asyncio.wait_for(forwarder_running.wait(), timeout=1.0)
supervisor_task.cancel()
with pytest.raises(asyncio.CancelledError):
await supervisor_task
# Forwarder ran exactly once and no backoff sleep happened —
# cancellation skipped the restart path entirely.
assert call_count == 1
assert sleeps == []
@pytest.mark.asyncio
async def test_supervise_forwarder_backoff_grows_on_repeated_crashes(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Consecutive crashes use exponentially growing backoff, capped at the max.
Prevents a fast-failing forwarder from POST-storming the Omnigent server
or burning CPU on tight-loop restarts.
"""
# 6 crashes is enough to walk past the cap: 1, 2, 4, 8, 16, 30
# (the 6th would naively be 32 but the cap clamps it to 30).
crash_budget = 6
call_count = 0
async def fake_forwarder(**_: Any) -> None:
"""Crash ``crash_budget`` times, then signal stop."""
nonlocal call_count
call_count += 1
if call_count <= crash_budget:
raise RuntimeError(f"simulated crash {call_count}")
raise asyncio.CancelledError()
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
"""Record sleep durations without waiting."""
sleeps.append(seconds)
# Pin monotonic so every run looks instantaneous and the
# healthy-uptime reset branch never fires.
monkeypatch.setattr(forwarder, "_supervisor_monotonic", lambda: 1000.0)
monkeypatch.setattr(forwarder, "forward_claude_transcript_to_session", fake_forwarder)
monkeypatch.setattr(forwarder, "_supervisor_sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.supervise_forwarder(**_supervisor_kwargs(tmp_path))
# 6 crashes → 6 sleeps with doubling backoff, last clamped to max.
# If the cap isn't being applied, the 6th entry would be 32.0
# instead of _SUPERVISOR_MAX_BACKOFF_S (30.0).
assert sleeps == [1.0, 2.0, 4.0, 8.0, 16.0, forwarder._SUPERVISOR_MAX_BACKOFF_S], (
f"Backoff should double up to the {forwarder._SUPERVISOR_MAX_BACKOFF_S}s "
f"cap; got {sleeps}."
)
@pytest.mark.asyncio
async def test_supervise_forwarder_resets_backoff_after_healthy_uptime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A long-running forwarder that eventually crashes resets backoff.
Without this, a forwarder that ran healthy for hours and then
hit a transient blip would still wait the full 30s before
restarting — penalizing successful long runs as if they were
a crash-loop.
"""
healthy_threshold = forwarder._SUPERVISOR_HEALTHY_UPTIME_S
call_count = 0
# The supervisor calls _supervisor_monotonic() twice per
# iteration: once at run_started_at, once at run_duration_s.
# We feed 4 iterations × 2 readings = 8 values, with run 3
# crossing the healthy threshold.
monotonic_values = iter(
[
# Run 1: short-lived (1s uptime). Backoff stays at initial.
0.0,
1.0,
# Run 2: short-lived (1s uptime). Backoff doubles.
10.0,
11.0,
# Run 3: long-lived (>= threshold). Backoff resets after
# this iteration completes.
20.0,
20.0 + healthy_threshold + 1.0,
# Run 4: short-lived. Should sleep the post-reset initial
# value, not the doubled-from-run-3 value.
200.0,
201.0,
],
)
async def fake_forwarder(**_: Any) -> None:
"""Crash 3 times to drive the reset, then signal stop on call 4."""
nonlocal call_count
call_count += 1
if call_count >= 4:
raise asyncio.CancelledError()
raise RuntimeError(f"simulated crash {call_count}")
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
"""Record backoff durations."""
sleeps.append(seconds)
monkeypatch.setattr(forwarder, "_supervisor_monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(forwarder, "forward_claude_transcript_to_session", fake_forwarder)
monkeypatch.setattr(forwarder, "_supervisor_sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.supervise_forwarder(**_supervisor_kwargs(tmp_path))
# Run 1 → sleep 1 (initial). Backoff grows to 2.
# Run 2 → sleep 2. Backoff grows to 4.
# Run 3 → healthy, backoff resets to initial BEFORE sleep, then
# doubles to 2 after sleep — so sleep value is the initial.
# Run 4 (CancelledError) → propagates, no further sleep.
# If the reset branch didn't fire, run 3's sleep would be 4.0
# instead of 1.0.
assert sleeps == [
1.0,
2.0,
forwarder._SUPERVISOR_INITIAL_BACKOFF_S,
], (
f"Healthy uptime should reset backoff before the post-iteration sleep; "
f"got {sleeps}. If the third entry is 4.0, the reset branch is not firing."
)
@pytest.mark.parametrize(
"raised_exc",
[SystemExit("shutdown"), KeyboardInterrupt()],
ids=["SystemExit", "KeyboardInterrupt"],
)
@pytest.mark.asyncio
async def test_supervise_forwarder_propagates_process_shutdown_signals(
raised_exc: BaseException,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
:class:`BaseException` subclasses used for shutdown are not swallowed.
The supervisor only restarts on :class:`Exception`. Process-level
signals (``KeyboardInterrupt`` from Ctrl-C, ``SystemExit`` from
``sys.exit()``) must propagate so the wrapper CLI shuts down
promptly instead of looping inside an "unkillable" supervisor.
"""
call_count = 0
async def fake_forwarder(**_: Any) -> None:
"""Raise the shutdown signal under test on the first call."""
nonlocal call_count
call_count += 1
raise raised_exc
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
"""Record sleeps; this path should never be reached."""
sleeps.append(seconds)
monkeypatch.setattr(forwarder, "forward_claude_transcript_to_session", fake_forwarder)
monkeypatch.setattr(forwarder, "_supervisor_sleep", fake_sleep)
with pytest.raises(type(raised_exc)):
await forwarder.supervise_forwarder(**_supervisor_kwargs(tmp_path))
# Forwarder ran exactly once and no backoff sleep happened — the
# shutdown signal propagated through the supervisor without a
# restart attempt. If call_count is 2+, the supervisor swallowed
# the signal (the regression).
assert call_count == 1
assert sleeps == []
# ── Native task state accumulation ───────────────────────────────────────────
async def _drain_todos_request(server: _RecordingHTTPServer) -> dict[str, Any]:
"""
Await the first ``external_session_todos`` POST from the forwarder.
:param server: Recording HTTP server.
:returns: The ``data`` payload of the matching POST body.
"""
while True:
req = await _get_recorded_request(server)
if req["body"].get("type") == "external_session_todos":
return req["body"]["data"]
def _record_session_start(bridge_dir: Path, transcript_path: Path) -> None:
"""
Write a ``SessionStart`` hook event so the forwarder enters its main loop.
:param bridge_dir: Bridge directory for ``record_hook_event``.
:param transcript_path: Transcript file path carried in the payload.
:returns: None.
"""
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
@pytest.mark.asyncio
async def test_forwarder_posts_todos_on_task_created(tmp_path: Path) -> None:
"""
A ``TaskCreated`` hook event causes the forwarder to POST an
``external_session_todos`` event with the new task at status ``"pending"``.
This fails if the ``TaskCreated`` branch in the forwarder's hook loop
fails to set ``native_todos_changed = True`` or if the ``todos_to_post``
list is not built from the accumulation maps.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_record_session_start(bridge_dir, transcript_path)
record_hook_event(
bridge_dir,
{
"hook_event_name": "TaskCreated",
"session_id": "claude-session",
"task_id": "1",
"task_subject": "Write integration tests",
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
data = await _drain_todos_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert data["todos"] == [
{
"content": "Write integration tests",
"status": "pending",
# activeForm equals content for native tasks (suppresses
# duplicate rendering in the panel).
"activeForm": "Write integration tests",
}
]
@pytest.mark.asyncio
async def test_forwarder_posts_todos_on_task_completed(tmp_path: Path) -> None:
"""
A ``TaskCreated`` followed by ``TaskCompleted`` causes a final POST
where the task has status ``"completed"``.
This fails if ``TaskCompleted`` does not update ``task_statuses`` or
if ``native_todos_changed`` is not set when it should be.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_record_session_start(bridge_dir, transcript_path)
record_hook_event(
bridge_dir,
{
"hook_event_name": "TaskCreated",
"session_id": "claude-session",
"task_id": "1",
"task_subject": "Fix the bug",
},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "TaskCompleted",
"session_id": "claude-session",
"task_id": "1",
},
)
server, thread, base_url = _start_recording_server()
# Drain two consecutive todos POSTs: one for TaskCreated, one for TaskCompleted.
posted: list[dict[str, Any]] = []
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
posted.append(await _drain_todos_request(server))
posted.append(await _drain_todos_request(server))
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# First POST: task is still pending (from TaskCreated).
assert posted[0]["todos"][0]["status"] == "pending"
# Second POST: task is completed (from TaskCompleted).
assert posted[1]["todos"][0]["status"] == "completed"
assert posted[1]["todos"][0]["content"] == "Fix the bug"
@pytest.mark.asyncio
async def test_forwarder_posts_raw_todos_on_todo_write(tmp_path: Path) -> None:
"""
A ``PostToolUse/TodoWrite`` hook event causes the forwarder to POST
the raw ``tool_input.todos`` list verbatim, bypassing accumulation.
This fails if the ``record.todos is not None`` branch is not taken
ahead of the native-task path, or if the list is modified before posting.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
raw_todos = [
{"content": "Write tests", "status": "in_progress", "activeForm": "Writing tests"},
{"content": "Review PR", "status": "pending", "activeForm": "Reviewing PR"},
]
_record_session_start(bridge_dir, transcript_path)
record_hook_event(
bridge_dir,
{
"hook_event_name": "PostToolUse",
"session_id": "claude-session",
"tool_name": "TodoWrite",
"tool_input": {"todos": raw_todos},
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
data = await _drain_todos_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# The raw list is forwarded verbatim — no accumulation or transformation.
assert data["todos"] == raw_todos
# ── Sub-agent watcher (Claude Code Task tool) ────────────
def _start_recording_server_with_responses(
response_for: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
) -> tuple[_RecordingHTTPServer, threading.Thread, str]:
"""
Start a local HTTP server that records POST bodies AND returns
a customizable response body.
Variant of :func:`_start_recording_server` for tests that need
the Omnigent server's response (rather than just a generic 202 ``{}``)
— used by the sub-agent watcher tests because
``external_subagent_start`` returns ``{"child_session_id": "..."}``
that the forwarder reads back.
:param response_for: Callback that takes the decoded request
body and returns the JSON dict to send back. ``None`` (the
default) responds with ``{}`` like the standard recorder.
:returns: ``(server, thread, base_url)``.
"""
requests: queue.Queue[dict[str, Any]] = queue.Queue()
class _Handler(BaseHTTPRequestHandler):
"""Recording handler with response customization."""
def log_message(self, format: str, *args: Any) -> None:
"""Suppress test HTTP server logging.
:param format: Log format string.
:param args: Log format arguments.
:returns: None.
"""
del format, args
def do_POST(self) -> None:
"""Record a JSON POST body and send a customizable response.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
body = json.loads(raw.decode("utf-8"))
requests.put({"method": "POST", "path": self.path, "body": body})
response_body = {} if response_for is None else response_for(body)
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(response_body).encode("utf-8"))
def do_PATCH(self) -> None:
"""Record a JSON PATCH body and respond ``{}``.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
requests.put(
{"method": "PATCH", "path": self.path, "body": json.loads(raw.decode("utf-8"))}
)
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
server = _RecordingHTTPServer(("127.0.0.1", 0), _Handler)
server.requests = requests
thread = threading.Thread(
target=server.serve_forever,
name="claude-forwarder-test-ap-subagent",
daemon=True,
)
thread.start()
host, port = server.server_address
return server, thread, f"http://{host}:{port}"
def _seed_subagent_on_disk(
*,
transcript_path: Path,
subagent_id: str,
agent_type: str,
description: str,
tool_use_id: str,
transcript_records: list[dict[str, Any]] | None = None,
) -> Path:
"""
Create the ``.meta.json`` + ``.jsonl`` pair Claude Code would
write for a Task-tool sub-agent.
Mirrors the on-disk layout the forwarder's watcher polls:
``<transcript_parent>/<transcript_stem>/subagents/agent-<id>.*``.
:param transcript_path: Parent transcript JSONL path. The sibling
``<stem>/subagents/`` directory is created next to it.
:param subagent_id: Stable Claude-side id (the ``agent-<id>``
filename stem), e.g. ``"a5c7eff..."``.
:param agent_type: ``agentType`` value for the meta file,
e.g. ``"Explore"``.
:param description: ``description`` value for the meta file.
:param tool_use_id: ``toolUseId`` value for the meta file.
:param transcript_records: Optional list of decoded transcript
rows to seed into the sub-agent's ``.jsonl``. ``None`` /
empty leaves the transcript empty (the common case when a
sub-agent has just been spawned).
:returns: Path to the sub-agent's ``.jsonl`` (handy for tests
that append rows after the fact).
"""
subagents_dir = transcript_path.parent / transcript_path.stem / "subagents"
subagents_dir.mkdir(parents=True, exist_ok=True)
meta_path = subagents_dir / f"agent-{subagent_id}.meta.json"
meta_path.write_text(
json.dumps(
{
"agentType": agent_type,
"description": description,
"toolUseId": tool_use_id,
}
),
encoding="utf-8",
)
jsonl_path = subagents_dir / f"agent-{subagent_id}.jsonl"
if transcript_records:
jsonl_path.write_text(
"\n".join(json.dumps(row) for row in transcript_records) + "\n",
encoding="utf-8",
)
else:
jsonl_path.write_text("", encoding="utf-8")
return jsonl_path
async def test_subagent_watcher_posts_external_subagent_start_for_new_meta(
tmp_path: Path,
) -> None:
"""
When a new ``agent-<id>.meta.json`` appears under the parent's
``subagents/`` dir, the forwarder POSTs ``external_subagent_start``
with the meta fields and persists the returned ``child_session_id``
in its durable cursor.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="a5c7eff",
agent_type="Explore",
description="Trace the auth flow",
tool_use_id="toolu_xyz",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
def response_for(body: dict[str, Any]) -> dict[str, Any]:
"""Return a minted child id for the subagent_start event.
:param body: Decoded request body.
:returns: Response payload.
"""
if body.get("type") == "external_subagent_start":
return {"queued": False, "child_session_id": "conv_child_alpha"}
return {}
server, _thread, base_url = _start_recording_server_with_responses(response_for)
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_parent",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Skip the transcript-status / mirror PATCHes that may land
# before our event, and stop at the first
# ``external_subagent_start`` we see.
start_req: dict[str, Any] | None = None
for _ in range(20):
req = await _get_recorded_request(server)
if req["body"].get("type") == "external_subagent_start":
start_req = req
break
assert start_req is not None, "forwarder did not POST external_subagent_start"
assert start_req["path"] == "/v1/sessions/conv_parent/events"
assert start_req["body"]["data"] == {
"subagent_id": "a5c7eff",
"agent_type": "Explore",
"description": "Trace the auth flow",
"tool_use_id": "toolu_xyz",
}
# The cursor persists the returned child id so a forwarder
# restart won't re-mint a duplicate row. Wait on it BEFORE
# cancelling so the writer's ``asyncio.to_thread`` has time
# to flush — cancellation can interrupt the inflight write.
cursor = await _wait_for_json_state(
bridge_dir / "subagent_forwarder.json",
lambda payload: "a5c7eff" in payload.get("subagents", {}),
)
assert cursor["subagents"]["a5c7eff"]["child_conversation_id"] == "conv_child_alpha"
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
async def test_subagent_watcher_forwards_transcript_items_to_child_session(
tmp_path: Path,
) -> None:
"""
After registering a sub-agent, the forwarder tails its
``.jsonl`` and POSTs ``external_conversation_item`` events to
the Omnigent child session id (not the parent's).
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="b6d8fff",
agent_type="Explore",
description="Trace data flow",
tool_use_id="toolu_abc",
# Real sub-agent transcripts carry ``isSidechain: true`` on
# every record (that's how Claude marks them as belonging to
# a child instead of the main thread). The parser's default
# behavior strips sidechain records, so without this flag
# the watcher silently posts zero items — pin the real shape
# here so a regression to that behavior fails this test.
transcript_records=[
{
"isSidechain": True,
"type": "user",
"uuid": "sa-user-1",
"message": {"role": "user", "content": "go"},
},
{
"isSidechain": True,
"type": "assistant",
"uuid": "sa-assistant-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "looking now"}],
},
},
],
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
def response_for(body: dict[str, Any]) -> dict[str, Any]:
"""Mint a known child id for the start event.
:param body: Decoded request body.
:returns: Response payload.
"""
if body.get("type") == "external_subagent_start":
return {"queued": False, "child_session_id": "conv_child_beta"}
return {}
server, _thread, base_url = _start_recording_server_with_responses(response_for)
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_parent",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# We need: the start event + at least one item event addressed
# to the child. Drain up to N requests and collect every
# request bound for the child's ``/events`` path.
child_path = "/v1/sessions/conv_child_beta/events"
child_requests: list[dict[str, Any]] = []
for _ in range(40):
req = await _get_recorded_request(server)
if req["path"] == child_path:
child_requests.append(req)
if len(child_requests) >= 2:
break
assert len(child_requests) >= 2, (
f"only saw {len(child_requests)} requests to {child_path}: {child_requests!r}"
)
item_types = [r["body"]["type"] for r in child_requests]
assert "external_conversation_item" in item_types
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
async def test_subagent_watcher_retry_skips_previously_posted_items(
tmp_path: Path,
) -> None:
"""
Retrying a failed child item does not re-post earlier child items.
The sub-agent watcher intentionally leaves ``byte_offset`` behind
when a later item fails, so the next poll re-reads the same JSONL
window. This test pins the durable ``seen_source_ids`` guard: item
A succeeds, item B fails once, and the retry must post only B.
Without that guard Omnigent live subscribers can see item A synced back
twice; the server no longer receives a ``source_id`` key that can
dedupe the post on AP's side.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
subagent_jsonl = _seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="retry1",
agent_type="Explore",
description="retry item flow",
tool_use_id="toolu_retry",
transcript_records=[
{
"isSidechain": True,
"type": "user",
"uuid": "sa-user-retry",
"message": {"role": "user", "content": "go"},
},
{
"isSidechain": True,
"type": "assistant",
"uuid": "sa-assistant-retry",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "done"}],
},
},
],
)
state = forwarder.SubagentForwardState(
subagents={
"retry1": forwarder.SubagentEntry(
subagent_id="retry1",
child_conversation_id="conv_child_retry",
)
}
)
posted_items: list[str] = []
attempts_by_item: dict[str, int] = {}
def handler(request: httpx.Request) -> httpx.Response:
"""
Fail the assistant item once and accept everything else.
:param request: Request issued by the forwarder.
:returns: Canned Omnigent response.
"""
body = json.loads(request.content.decode("utf-8"))
if body.get("type") != "external_conversation_item":
return httpx.Response(202, json={})
item_data = body["data"]["item_data"]
role = item_data["role"]
text = item_data["content"][0]["text"]
item_key = f"{role}:{text}"
posted_items.append(item_key)
attempts_by_item[item_key] = attempts_by_item.get(item_key, 0) + 1
if item_key == "assistant:done" and attempts_by_item[item_key] == 1:
return httpx.Response(503, json={"error": "try again"})
return httpx.Response(202, json={})
item_retry_tracker = forwarder._PostRetryTracker(base_delay_s=0.0)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="http://ap",
) as client:
first = await forwarder._forward_available_subagents(
client=client,
parent_session_id="conv_parent",
bridge_dir=bridge_dir,
transcript_path=transcript_path,
state=state,
agent_name="claude-native-ui",
start_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
item_retry_tracker=item_retry_tracker,
status_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
)
second = await forwarder._forward_available_subagents(
client=client,
parent_session_id="conv_parent",
bridge_dir=bridge_dir,
transcript_path=transcript_path,
state=first,
agent_name="claude-native-ui",
start_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
item_retry_tracker=item_retry_tracker,
status_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
)
assert posted_items == ["user:go", "assistant:done", "assistant:done"]
child_state = second.subagents["retry1"]
assert child_state.byte_offset == subagent_jsonl.stat().st_size
assert set(child_state.seen_source_ids) == {
"sa-user-retry:0:message",
"sa-assistant-retry:0:message",
}
async def test_subagent_watcher_skips_subagents_already_in_state(
tmp_path: Path,
) -> None:
"""
On forwarder restart, sub-agents already in
``subagent_forwarder.json`` are NOT re-registered (no second
``external_subagent_start`` POST). This is the idempotency
contract the cursor file is for — without it, a forwarder
crash-loop would mint a new child Conversation per restart.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="c0ldc4t",
agent_type="Explore",
description="post-restart sub-agent",
tool_use_id="toolu_qqq",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
# Pre-seed the cursor as if a previous forwarder ran already.
bridge_dir.mkdir(parents=True, exist_ok=True)
(bridge_dir / "subagent_forwarder.json").write_text(
json.dumps(
{
"subagents": {
"c0ldc4t": {
"child_conversation_id": "conv_child_existing",
"byte_offset": 0,
"last_activity_ts": None,
"last_status": None,
}
},
"updated_at": 0,
}
),
encoding="utf-8",
)
starts: list[dict[str, Any]] = []
def response_for(body: dict[str, Any]) -> dict[str, Any]:
"""Capture any start events and fail the test loudly.
:param body: Decoded request body.
:returns: Response payload (unused, since we don't expect a
start event in this scenario).
"""
if body.get("type") == "external_subagent_start":
starts.append(body)
return {"queued": False, "child_session_id": "conv_unexpected"}
return {}
server, _thread, base_url = _start_recording_server_with_responses(response_for)
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_parent",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
# Let the forwarder run a few ticks. Long enough to scan the
# subagents dir at least twice; if it would re-register, we'd
# see the POST in ``starts`` within this window.
try:
await asyncio.sleep(0.2)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
assert starts == [], (
f"forwarder re-registered a sub-agent that was already in state: {starts!r}"
)
async def test_subagent_watcher_preserves_parked_sentinel_across_restart(
tmp_path: Path,
) -> None:
"""
A sub-agent that exhausted its permanent-failure budget is "parked"
by writing an empty ``child_conversation_id`` sentinel into the
cursor. On restart we must round-trip that sentinel — otherwise the
parked sub-agent silently disappears from state and the next tick
retries it (defeating the failure-budget cap).
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="parked-cat",
agent_type="Explore",
description="exhausted start retries last time",
tool_use_id="toolu_parked",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
# Pre-seed the cursor with a parked entry — empty child id is the
# sentinel ``_forward_available_subagents`` writes on exhaustion.
bridge_dir.mkdir(parents=True, exist_ok=True)
(bridge_dir / "subagent_forwarder.json").write_text(
json.dumps(
{
"subagents": {
"parked-cat": {
"child_conversation_id": "",
"byte_offset": 0,
"last_activity_ts": None,
"last_status": None,
}
},
"updated_at": 0,
}
),
encoding="utf-8",
)
starts: list[dict[str, Any]] = []
def response_for(body: dict[str, Any]) -> dict[str, Any]:
"""Record any start POSTs — none should arrive for the parked id.
:param body: Decoded request body.
:returns: Response payload.
"""
if body.get("type") == "external_subagent_start":
starts.append(body)
return {"queued": False, "child_session_id": "conv_should_not_be_used"}
return {}
server, _thread, base_url = _start_recording_server_with_responses(response_for)
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_parent",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
await asyncio.sleep(0.2)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
assert starts == [], f"forwarder retried a parked sub-agent after restart: {starts!r}"
# ---------------------------------------------------------------------------
# In-pane /effort → Omnigent session reasoning_effort mirroring
# ---------------------------------------------------------------------------
@dataclass
class _CapturedRequest:
"""
One request seen by the effort-sync mock transport.
:param method: HTTP method, e.g. ``"PATCH"``.
:param path: Request path, e.g. ``"/v1/sessions/conv_x"``.
:param body: Parsed JSON body, or ``None`` when the request had no body.
"""
method: str
path: str
body: dict[str, Any] | None
def _slash_command_item(*, name: str, arguments: str) -> ClaudeTranscriptItem:
"""
Build a ``slash_command`` transcript item as the bridge emits it.
:param name: Command name with the leading ``/`` already stripped,
e.g. ``"effort"``.
:param arguments: Verbatim ``<command-args>`` text, e.g. ``"max"``.
:returns: A ``slash_command`` item shaped like
:func:`_user_transcript_items_from_entry` produces.
"""
return ClaudeTranscriptItem(
source_id="rec01:0:slash_command",
item_type="slash_command",
data={"agent": "claude", "kind": "command", "name": name, "arguments": arguments},
response_id="resp_1",
)
async def _run_effort_sync(
item: ClaudeTranscriptItem,
*,
status: int = 200,
) -> list[_CapturedRequest]:
"""
Drive ``_maybe_sync_effort_from_slash_command`` against a mock AP.
:param item: Transcript item to feed the helper.
:param status: HTTP status the mock PATCH endpoint returns, e.g.
``503`` to exercise the best-effort swallow path.
:returns: Every request the helper issued, in order.
"""
captured: list[_CapturedRequest] = []
def handler(request: httpx.Request) -> httpx.Response:
"""Record the request and return a canned PATCH response."""
body = json.loads(request.content.decode("utf-8")) if request.content else None
captured.append(_CapturedRequest(method=request.method, path=request.url.path, body=body))
return httpx.Response(status, json={"id": "conv_x"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
await forwarder._maybe_sync_effort_from_slash_command(
client, session_id="conv_x", item=item
)
return captured
@pytest.mark.parametrize("level", sorted(CLAUDE_EFFORTS))
async def test_in_pane_effort_set_patches_session_silently(level: str) -> None:
"""``/effort <level>`` in the pane PATCHes reasoning_effort with silent=True."""
captured = await _run_effort_sync(_slash_command_item(name="effort", arguments=level))
assert captured == [
_CapturedRequest(
method="PATCH",
path="/v1/sessions/conv_x",
body={"reasoning_effort": level, "silent": True},
)
], f"expected one silent reasoning_effort={level} PATCH, got {captured!r}"
@pytest.mark.parametrize("alias", sorted(EFFORT_CLEAR_VALUES))
async def test_in_pane_effort_clear_patches_clear_alias(alias: str) -> None:
"""``/effort default`` (and off/reset) forwards the clear alias verbatim."""
captured = await _run_effort_sync(_slash_command_item(name="effort", arguments=alias))
assert captured == [
_CapturedRequest(
method="PATCH",
path="/v1/sessions/conv_x",
body={"reasoning_effort": alias, "silent": True},
)
], f"expected one silent clear PATCH for alias={alias}, got {captured!r}"
def _message_item() -> ClaudeTranscriptItem:
"""A plain user-message item (not a slash command)."""
return ClaudeTranscriptItem(
source_id="rec01:0:message",
item_type="message",
data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
response_id="resp_1",
)
@pytest.mark.parametrize(
"item",
[
pytest.param(_slash_command_item(name="effort", arguments="turbo"), id="unknown-level"),
pytest.param(_slash_command_item(name="effort", arguments=""), id="no-arg-show"),
pytest.param(_slash_command_item(name="model", arguments="opus"), id="non-effort-command"),
pytest.param(_message_item(), id="plain-message"),
],
)
async def test_effort_sync_skips_non_effort_changes(item: ClaudeTranscriptItem) -> None:
"""Only a recognized ``/effort`` set/clear PATCHes; everything else no-ops."""
captured = await _run_effort_sync(item)
assert captured == [], f"expected no PATCH for this item, got {captured!r}"
async def test_effort_sync_swallows_patch_failure() -> None:
"""A failed PATCH is best-effort — attempted, logged, never raised."""
captured = await _run_effort_sync(
_slash_command_item(name="effort", arguments="max"), status=503
)
# Attempted exactly once and the 503 swallowed (no exception escaped the await).
assert len(captured) == 1
assert captured[0].method == "PATCH"
def test_usage_from_status_state_surfaces_cumulative_cost() -> None:
"""
``_usage_from_status_state`` surfaces ``total_cost_usd`` as
``cumulative_cost_usd`` so the forwarder posts it for native cost tracking.
Failure means Claude Code's captured cost never reaches the server, so
native ``session_usage.total_cost_usd`` stays 0.
"""
state = {
"context_window_size": 1_000_000,
"current_usage": {"input_tokens": 6, "output_tokens": 50},
"total_cost_usd": 0.42,
}
result = forwarder._usage_from_status_state(state)
assert result is not None
assert result["cumulative_cost_usd"] == 0.42
# Token fields still flow for the context ring.
assert result["input_tokens"] == 6
assert result["output_tokens"] == 50
def test_usage_from_status_state_omits_cost_when_absent() -> None:
"""
Without ``total_cost_usd`` in state, no ``cumulative_cost_usd`` is emitted.
Older Claude Code versions (or a statusLine without a cost block) must not
cause a bogus 0-cost post that would overwrite a real value with SET.
"""
state = {
"context_window_size": 1_000_000,
"current_usage": {"input_tokens": 6, "output_tokens": 50},
}
result = forwarder._usage_from_status_state(state)
assert result is not None
assert "cumulative_cost_usd" not in result
@pytest.fixture
def otel_exporter(monkeypatch: pytest.MonkeyPatch) -> Iterator[InMemorySpanExporter]:
"""
Install a fresh TracerProvider with an in-memory exporter for one test.
Restores the previous provider on teardown so OTel's set-once
semantics do not leak into later tests in the same process.
"""
monkeypatch.setenv("OMNIGENT_TELEMETRY_ENABLED", "true")
previous = otel_trace._TRACER_PROVIDER # type: ignore[attr-defined]
previous_done = otel_trace._TRACER_PROVIDER_SET_ONCE._done # type: ignore[attr-defined]
in_mem = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(in_mem))
otel_trace._TRACER_PROVIDER = provider # type: ignore[attr-defined]
otel_trace._TRACER_PROVIDER_SET_ONCE._done = True # type: ignore[attr-defined]
try:
yield in_mem
finally:
in_mem.clear()
with contextlib.suppress(Exception):
provider.shutdown()
otel_trace._TRACER_PROVIDER = previous # type: ignore[attr-defined]
otel_trace._TRACER_PROVIDER_SET_ONCE._done = previous_done # type: ignore[attr-defined]
def _ok_usage_client() -> httpx.AsyncClient:
"""
Build a client whose ``POST /events`` always succeeds.
:returns: Client backed by a mock transport returning ``200``.
"""
return httpx.AsyncClient(
transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={})),
base_url="http://omnigent.test",
)
@pytest.mark.asyncio
async def test_post_session_usage_records_gen_ai_token_attributes(
otel_exporter: InMemorySpanExporter,
) -> None:
"""
A native Claude usage post carries the turn's tokens as ``gen_ai.usage.*``.
A native turn runs to completion in the terminal, so the harness
executor's ``TurnComplete`` reports no usage and the agent span closes
without token attributes. This post is where the real counts are known,
so it must record them or the session's tokens stay invisible to
MLflow / any OTel backend.
"""
async with _ok_usage_client() as client:
await forwarder._post_external_session_usage(
client,
session_id="conv_abc123",
usage={"context_tokens": 1773, "input_tokens": 1523, "output_tokens": 847},
context_window=200_000,
token_usage={
"input_tokens": 1523,
"output_tokens": 847,
"cache_read_input_tokens": 200,
"cache_creation_input_tokens": 50,
},
)
spans = [s for s in otel_exporter.get_finished_spans() if s.name == "claude_native.usage"]
assert len(spans) == 1
attrs = dict(spans[0].attributes or {})
assert attrs["gen_ai.usage.input_tokens"] == 1523
assert attrs["gen_ai.usage.output_tokens"] == 847
assert attrs["gen_ai.usage.total_tokens"] == 1523 + 847
assert attrs["gen_ai.usage.cache_read_input_tokens"] == 200
assert attrs["gen_ai.usage.cache_creation_input_tokens"] == 50
@pytest.mark.asyncio
async def test_post_session_usage_without_token_usage_records_no_tokens(
otel_exporter: InMemorySpanExporter,
) -> None:
"""
A post with no ``token_usage`` records no token attributes.
Cost posts and context-window-only posts reach the same helper carrying
a usage snapshot but no new counts. Falling back to that snapshot would
re-record a figure already counted, or report a 0-token turn on every
cost tick.
"""
async with _ok_usage_client() as client:
await forwarder._post_external_session_usage(
client,
session_id="conv_abc123",
usage={"cumulative_cost_usd": 0.42, "model": "claude-opus-4-8"},
)
spans = [s for s in otel_exporter.get_finished_spans() if s.name == "claude_native.usage"]
assert len(spans) == 1
attrs = dict(spans[0].attributes or {})
assert not [key for key in attrs if key.startswith("gen_ai.usage.")]
@pytest.mark.asyncio
async def test_forwarder_records_each_api_call_usage_exactly_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
otel_exporter: InMemorySpanExporter,
) -> None:
"""
Each assistant API call contributes exactly one usage span.
The usage POST re-fires whenever the statusLine gauge or the context
window moves, which happens several times per API call. Recording the
snapshot on each of those would make a backend that SUMS
``gen_ai.usage.*`` across spans multiply-count the same prompt. Only a
new completed assistant record may add a span.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
def _assistant(uuid: str, text: str, usage: dict[str, int]) -> str:
"""
Build one assistant JSONL line carrying ``message.usage``.
:param uuid: Transcript entry uuid, e.g. ``"a1"``.
:param text: Assistant text content.
:param usage: Anthropic ``message.usage`` block for the call.
:returns: A JSON-encoded transcript line.
"""
return json.dumps(
{
"type": "assistant",
"uuid": uuid,
"message": {
"role": "assistant",
"model": "claude-opus-4-8",
"content": [{"type": "text", "text": text}],
"usage": usage,
},
}
)
# The statusLine gauge moves every poll (a streaming message's output
# grows, cache reads land) — the churn that used to re-record tokens.
status_box = {"value": {"input_tokens": 1000, "output_tokens": 10}}
monkeypatch.setattr(
forwarder,
"read_claude_context_state",
lambda _bridge: {"context_window_size": 200_000, "current_usage": status_box["value"]},
)
transcript_path.write_text(
_assistant("a1", "hi", {"input_tokens": 1000, "output_tokens": 50}) + "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
dedupe = forwarder._ForwardDedupeState()
retry_tracker = forwarder._PostRetryTracker()
transport = httpx.MockTransport(lambda _request: httpx.Response(202, json={}))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async def poll() -> None:
"""Run one forwarder poll against the shared cursor state."""
nonlocal state
state = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
await poll()
# Same API call, gauge still moving: re-posts usage, records nothing.
status_box["value"] = {"input_tokens": 1000, "output_tokens": 40}
await poll()
status_box["value"] = {
"input_tokens": 1000,
"output_tokens": 50,
"cache_read_input_tokens": 900,
}
await poll()
recorded = _recorded_token_spans(otel_exporter)
assert recorded == [(1000, 50)], "one completed API call must record exactly one span"
# A second API call is new usage and does add a span.
with transcript_path.open("a", encoding="utf-8") as fh:
fh.write(_assistant("a2", "more", {"input_tokens": 2200, "output_tokens": 80}) + "\n")
await poll()
assert _recorded_token_spans(otel_exporter) == [(1000, 50), (2200, 80)]
assert dedupe.recorded_token_usage == {"input_tokens": 2200, "output_tokens": 80}
def _recorded_token_spans(exporter: InMemorySpanExporter) -> list[tuple[int, int]]:
"""
Collect ``(input_tokens, output_tokens)`` from every usage span recorded.
:param exporter: In-memory exporter holding the finished spans.
:returns: One pair per span that carried token attributes, in order.
"""
pairs: list[tuple[int, int]] = []
for span in exporter.get_finished_spans():
attrs = dict(span.attributes or {})
if "gen_ai.usage.input_tokens" in attrs:
pairs.append((attrs["gen_ai.usage.input_tokens"], attrs["gen_ai.usage.output_tokens"]))
return pairs
@pytest.mark.parametrize(
("usage", "expected"),
[
# The cost tag and the derived context gauge are not token counters.
(
{"context_tokens": 1773, "input_tokens": 1523, "output_tokens": 847},
{"input_tokens": 1523, "output_tokens": 847},
),
({"cumulative_cost_usd": 0.42, "model": "claude-opus-4-8"}, None),
({"context_tokens": 1773}, None),
(None, None),
],
)
def test_gen_ai_usage_tokens_keeps_only_token_counters(
usage: dict[str, float | str] | None,
expected: dict[str, int] | None,
) -> None:
"""
Only real input/output token counters survive into the OTel payload.
:param usage: Usage payload posted to the Sessions API.
:param expected: Token counts to record, or ``None`` for no recording.
"""
assert forwarder._gen_ai_usage_tokens(usage) == expected
@dataclass
class _CapturedDeltaPost:
"""
One ``POST /events`` body captured during a delta-forwarding test.
:param url_path: Request URL path, e.g. ``"/v1/sessions/conv_x/events"``.
:param body: Parsed JSON request body.
"""
url_path: str
body: dict[str, Any]
def _write_deltas_file(bridge_dir: Path, records: list[dict[str, Any]]) -> None:
"""
Append delta records to ``message_deltas.jsonl`` as the hook would.
:param bridge_dir: Bridge directory.
:param records: Delta dicts to serialize one-per-line, e.g.
``[{"message_id": "m1", "index": 0, "final": True, "delta": "hi"}]``.
:returns: None.
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
with (bridge_dir / "message_deltas.jsonl").open("a", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record) + "\n")
def _delta_capture_client(
captured: list[_CapturedDeltaPost],
status_code: int = 202,
) -> httpx.AsyncClient:
"""
Build an AsyncClient whose ``/events`` POSTs are captured.
:param captured: List appended to with each observed POST body.
:param status_code: HTTP status the stub returns, e.g. ``202`` for
success or ``500`` to exercise the best-effort drop path.
:returns: An ``httpx.AsyncClient`` bound to the capturing transport.
"""
def handler(request: httpx.Request) -> httpx.Response:
captured.append(
_CapturedDeltaPost(url_path=request.url.path, body=json.loads(request.content))
)
return httpx.Response(status_code, json={"queued": False})
return httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="http://ap")
async def test_forward_available_deltas_posts_each_and_advances_offset(tmp_path: Path) -> None:
"""
Each appended chunk is POSTed as an ``external_output_text_delta``.
Proves the forwarder turns deltas-file lines into the exact event
shape the Omnigent route expects (delta + message_id + index + final) and
advances+persists the byte offset so the next poll resumes after
them. Fails if a field is dropped (UI can't scope/order the buffer)
or the offset doesn't persist (chunks re-POST on restart).
"""
bridge_dir = prepare_bridge_dir("conv_x", bridge_id="b1", workspace=tmp_path)
_write_deltas_file(
bridge_dir,
[
{"message_id": "m1", "index": 0, "final": False, "delta": "Hello "},
{"message_id": "m1", "index": 1, "final": True, "delta": "world"},
],
)
captured: list[_CapturedDeltaPost] = []
seen: dict[tuple[str, int], None] = {}
async with _delta_capture_client(captured) as client:
new_state = await forwarder._forward_available_deltas(
client=client,
session_id="conv_x",
bridge_dir=bridge_dir,
state=forwarder.DeltaForwardState(),
seen_keys=seen,
)
assert [c.url_path for c in captured] == [
"/v1/sessions/conv_x/events",
"/v1/sessions/conv_x/events",
]
# Full event shape proves every field survived hook → file → POST.
assert [c.body for c in captured] == [
{
"type": "external_output_text_delta",
"data": {"delta": "Hello ", "message_id": "m1", "index": 0, "final": False},
},
{
"type": "external_output_text_delta",
"data": {"delta": "world", "message_id": "m1", "index": 1, "final": True},
},
]
# Offset advanced to EOF and was persisted, so a reload resumes past
# the two chunks instead of re-POSTing them.
assert new_state.byte_offset == os.path.getsize(bridge_dir / "message_deltas.jsonl")
assert forwarder._read_delta_forward_state(bridge_dir).byte_offset == new_state.byte_offset
async def test_forward_available_deltas_dedupes_by_message_id_and_index(tmp_path: Path) -> None:
"""
A repeated ``(message_id, index)`` is POSTed at most once.
The byte offset prevents re-reads on the happy path, but a file
truncation/rewind can replay records; the in-memory seen-ring must
still suppress the duplicate. Fails if the dedupe key is wrong (or
absent), which would double-render a chunk in the live preview.
"""
bridge_dir = prepare_bridge_dir("conv_x", bridge_id="b1", workspace=tmp_path)
_write_deltas_file(
bridge_dir,
[
{"message_id": "m1", "index": 0, "final": False, "delta": "dup"},
{"message_id": "m1", "index": 0, "final": False, "delta": "dup"},
{"message_id": "m1", "index": 1, "final": True, "delta": "next"},
],
)
captured: list[_CapturedDeltaPost] = []
seen: dict[tuple[str, int], None] = {}
async with _delta_capture_client(captured) as client:
await forwarder._forward_available_deltas(
client=client,
session_id="conv_x",
bridge_dir=bridge_dir,
state=forwarder.DeltaForwardState(),
seen_keys=seen,
)
# The duplicate (m1, 0) is collapsed: only the first (m1,0) and the
# distinct (m1,1) are POSTed — 2 requests, not 3.
assert [(c.body["data"]["message_id"], c.body["data"]["index"]) for c in captured] == [
("m1", 0),
("m1", 1),
]
async def test_forward_available_deltas_drops_on_http_error(tmp_path: Path) -> None:
"""
A failed delta POST is swallowed and the offset still advances.
Deltas are an ephemeral preview; the authoritative final message
arrives via ``external_conversation_item`` regardless, so a transient
Omnigent blip must not raise or wedge the tail. Fails if the error
propagates (would crash the forwarder loop) or the offset stalls
(would re-POST the failed chunk forever).
"""
bridge_dir = prepare_bridge_dir("conv_x", bridge_id="b1", workspace=tmp_path)
_write_deltas_file(
bridge_dir, [{"message_id": "m1", "index": 0, "final": True, "delta": "boom"}]
)
captured: list[_CapturedDeltaPost] = []
seen: dict[tuple[str, int], None] = {}
async with _delta_capture_client(captured, status_code=500) as client:
new_state = await forwarder._forward_available_deltas(
client=client,
session_id="conv_x",
bridge_dir=bridge_dir,
state=forwarder.DeltaForwardState(),
seen_keys=seen,
)
# The POST was attempted (and 500'd) but no exception escaped, and
# the offset moved past the chunk so it won't be retried endlessly.
assert len(captured) == 1
assert new_state.byte_offset == os.path.getsize(bridge_dir / "message_deltas.jsonl")
def test_delta_forward_state_round_trips(tmp_path: Path) -> None:
"""
The delta cursor persists and reloads its byte offset.
Fails if the on-disk shape changes without the reader keeping up —
a forwarder restart would then re-stream the whole deltas file.
"""
bridge_dir = prepare_bridge_dir("conv_x", bridge_id="b1", workspace=tmp_path)
# A fresh read with no state file starts at offset 0.
assert forwarder._read_delta_forward_state(bridge_dir).byte_offset == 0
forwarder._write_delta_forward_state(bridge_dir, forwarder.DeltaForwardState(byte_offset=512))
assert forwarder._read_delta_forward_state(bridge_dir).byte_offset == 512
def test_transcript_forward_state_persists_settled_response_id(tmp_path: Path) -> None:
"""
The turn-settle latch survives a forwarder restart via the cursor file.
A restart inside a scheduled-wake gap must still mark the wake; a
pre-latch state file (no ``settled_response_id`` key) loads as None.
"""
bridge_dir = prepare_bridge_dir("conv_x", bridge_id="b2", workspace=tmp_path)
transcript = tmp_path / "session.jsonl"
state = forwarder.TranscriptForwardState(
transcript_path=transcript,
line_cursor=3,
byte_offset=64,
current_response_id="resp_a",
settled_response_id="resp_a",
pending_settled_response_id="resp_b",
)
forwarder._write_forward_state(bridge_dir, state)
loaded = forwarder._read_forward_state(bridge_dir)
assert loaded is not None
assert loaded.settled_response_id == "resp_a"
assert loaded.pending_settled_response_id == "resp_b"
raw = json.loads((bridge_dir / forwarder._FORWARDER_STATE_FILE).read_text("utf-8"))
del raw["settled_response_id"]
(bridge_dir / forwarder._FORWARDER_STATE_FILE).write_text(json.dumps(raw), "utf-8")
legacy = forwarder._read_forward_state(bridge_dir)
assert legacy is not None
assert legacy.settled_response_id is None
def test_promote_pending_settle_waits_for_turn_quiescence() -> None:
"""
A pending settle activates only once its turn has no output in flight.
The turn's final message can surface after its Stop edge — promoting
while that batch still
carries the turn's output would mis-read the tail as a scheduled
wake and split the answer into a phantom new turn.
"""
dedupe = forwarder._ForwardDedupeState()
dedupe.pending_settled_response_id = "resp_a"
tail = ClaudeTranscriptItem(
source_id="s1:0:message",
item_type="message",
data={"role": "assistant", "content": [{"type": "output_text", "text": "tail"}]},
response_id="resp_a",
)
assert forwarder._promote_pending_settle(dedupe, [tail]) is False
assert dedupe.settled_response_id is None
assert dedupe.pending_settled_response_id == "resp_a"
# A late tool result also defers: it can surface EARLIER than the
# assistant tail, and promoting on it would mis-mark that tail.
late_result = ClaudeTranscriptItem(
source_id="s2:0:function_call_output",
item_type="function_call_output",
data={"call_id": "c1", "output": "done"},
response_id="resp_a",
)
assert forwarder._promote_pending_settle(dedupe, [late_result]) is False
assert dedupe.pending_settled_response_id == "resp_a"
# Items for OTHER turns don't defer; a truly quiet batch promotes.
other = ClaudeTranscriptItem(
source_id="s3:0:message",
item_type="message",
data={"role": "assistant", "content": [{"type": "output_text", "text": "hi"}]},
response_id="resp_b",
)
assert forwarder._promote_pending_settle(dedupe, [other]) is True
assert dedupe.settled_response_id == "resp_a"
assert dedupe.pending_settled_response_id is None
# Idempotent once promoted.
assert forwarder._promote_pending_settle(dedupe, []) is False
@pytest.mark.asyncio
async def test_scheduled_wake_forwards_marker_under_a_new_turn_id(tmp_path: Path) -> None:
"""
The full wake pipeline: settle → quiet-poll promote → marked new turn.
Poll 1 forwards a turn; its Stop edge records the pending settle
(covered by the status-events test — recorded directly here). Poll 2
is quiet and promotes the settle, persisting it. Poll 3 sees new
assistant entries — a cron firing writes no user entry — and must POST
the scheduled-wake marker ahead of the resumed output, all under a new
response id.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "iter-one",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Iteration 1: all green."}],
},
}
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Accept every forwarder POST, recording its payload.
:param request: Outbound HTTP request from the forwarder.
:returns: HTTP 202 for the mock Omnigent endpoint.
"""
payload = json.loads(request.content.decode("utf-8"))
assert isinstance(payload, dict)
requests.append(payload)
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
dedupe = forwarder._ForwardDedupeState()
after_turn = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=forwarder._PostRetryTracker(),
dedupe=dedupe,
)
turn_one_id = after_turn.current_response_id
assert turn_one_id is not None
# The turn ends: the Stop edge records the pending settle.
dedupe.pending_settled_response_id = turn_one_id
quiet = await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=after_turn,
retry_tracker=forwarder._PostRetryTracker(),
dedupe=dedupe,
)
assert dedupe.settled_response_id == turn_one_id
assert quiet.settled_response_id == turn_one_id
# A cron firing appends assistant output with NO user entry.
with transcript_path.open("a", encoding="utf-8") as handle:
handle.write(
json.dumps(
{
"type": "assistant",
"uuid": "iter-two",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Iteration 2: still green."}],
},
}
)
+ "\n"
)
requests.clear()
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=quiet,
retry_tracker=forwarder._PostRetryTracker(),
dedupe=dedupe,
)
# No status POST: the transcript path publishes none (Claude's status file
# owns the badge). The wake is observable entirely in the items — a fresh
# turn id plus the marker ahead of the resumed output.
assert [request["type"] for request in requests] == ["external_conversation_item"] * len(
requests
)
items = [r["data"] for r in requests if r["type"] == "external_conversation_item"]
wake_turn_id = items[0]["response_id"]
assert wake_turn_id != turn_one_id
assert [item["item_data"]["role"] for item in items] == ["user", "assistant"]
assert items[0]["item_data"]["content"] == [
{"type": "input_text", "text": "[System: scheduled prompt fired]"}
]
assert {item["response_id"] for item in items} == {wake_turn_id}
async def test_post_external_output_text_delta_sends_expected_payload(tmp_path: Path) -> None:
"""
The single-delta POST helper sends the canonical event body.
Guards the wire contract between the forwarder and the AP
``/events`` route in isolation from the file-tailing logic.
"""
captured: list[_CapturedDeltaPost] = []
async with _delta_capture_client(captured) as client:
await forwarder._post_external_output_text_delta(
client,
session_id="conv_y",
delta=ClaudeMessageDelta(message_id="m9", index=4, final=True, delta="tok"),
)
assert captured == [
_CapturedDeltaPost(
url_path="/v1/sessions/conv_y/events",
body={
"type": "external_output_text_delta",
"data": {"delta": "tok", "message_id": "m9", "index": 4, "final": True},
},
)
]
# ── session cost reconciliation (max(S, C)) ───────────────────────────
@pytest.mark.parametrize(
"state,expected",
[
({"total_cost_usd": 0.5}, 0.5),
({"total_cost_usd": 0}, 0.0),
({"total_cost_usd": -1.0}, None), # negative rejected
({"total_cost_usd": True}, None), # bool rejected (not a real cost)
({"total_cost_usd": "x"}, None), # non-numeric rejected
({}, None), # absent
(None, None), # no statusLine yet
],
)
def test_cumulative_cost_from_status_state(
state: dict[str, Any] | None, expected: float | None
) -> None:
"""Only a non-negative numeric ``total_cost_usd`` yields a cost."""
assert forwarder._cumulative_cost_from_status_state(state) == expected
def test_transcript_cost_size_cached_recomputes_only_on_growth(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
The cost is recomputed only when the transcript's byte size changes.
Guards the per-poll optimization: an unchanged transcript must not be
re-parsed every 0.25s tick, but a grown one must be re-priced.
"""
calls: list[Path] = []
def fake_compute(path: Path, *, include_sidechains: bool) -> float | None:
calls.append(path)
return float(path.stat().st_size)
monkeypatch.setattr(forwarder, "compute_transcript_cumulative_cost", fake_compute)
cache: dict[Path, forwarder._TranscriptCostCacheEntry] = {}
path = tmp_path / "t.jsonl"
path.write_text("abc", encoding="utf-8") # 3 bytes
assert forwarder._transcript_cost_size_cached(
path, include_sidechains=True, cache=cache
) == pytest.approx(3.0)
# Second call at the same size → served from cache, no recompute.
assert forwarder._transcript_cost_size_cached(
path, include_sidechains=True, cache=cache
) == pytest.approx(3.0)
assert len(calls) == 1
# File grows → recompute.
path.write_text("abcdef", encoding="utf-8") # 6 bytes
assert forwarder._transcript_cost_size_cached(
path, include_sidechains=True, cache=cache
) == pytest.approx(6.0)
assert len(calls) == 2
# Missing file → None, no recompute attempt recorded as a priced call.
assert (
forwarder._transcript_cost_size_cached(
tmp_path / "missing.jsonl", include_sidechains=True, cache=cache
)
is None
)
def test_session_cost_estimate_takes_max_of_status_and_transcript_sum(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
``C`` sums parent + sub-agent transcript cost; the result is max(S, C).
During a sub-agent run the real-time transcript sum (C) exceeds the
lagging statusLine total (S), so C is used — this is what lets the
parent budget see the sub-agent's spend mid-turn. Once S settles
higher than C, S is used.
"""
parent = tmp_path / "sess.jsonl"
parent.write_text("parent", encoding="utf-8")
subagents_dir = forwarder._subagents_dir_for_transcript(parent)
subagents_dir.mkdir(parents=True)
sub_path = subagents_dir / "agent-aaa.jsonl"
sub_path.write_text("sub", encoding="utf-8")
per_path_cost = {parent: 0.10, sub_path: 0.55}
def fake_compute(path: Path, *, include_sidechains: bool) -> float | None:
return per_path_cost.get(path)
monkeypatch.setattr(forwarder, "compute_transcript_cumulative_cost", fake_compute)
entries = [forwarder.SubagentEntry(subagent_id="aaa", child_conversation_id="conv_child")]
# S stale ($0.005) < C (0.10 + 0.55 = 0.65) → C wins (mid-run).
assert forwarder._session_cost_estimate(
parent_transcript_path=parent,
active_subagents=entries,
status_cost=0.005,
cost_cache={},
) == pytest.approx(0.65)
# S settled ($2.00) > C → S wins (no double-count after settle).
assert forwarder._session_cost_estimate(
parent_transcript_path=parent,
active_subagents=entries,
status_cost=2.0,
cost_cache={},
) == pytest.approx(2.0)
@pytest.mark.asyncio
async def test_forward_session_cost_splits_display_and_policy(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
``_forward_session_cost`` posts S for display and max(S, C) for policy.
With a sub-agent present the two fields advance independently and
monotonically:
- ``cumulative_cost_usd`` (display) = the statusLine total S verbatim,
so the badge matches ``/cost``. It stays frozen while S is frozen,
then jumps when the turn settles.
- ``policy_cost_usd`` (enforcement) = max(S, transcript estimate C),
so the gate sees in-flight sub-agent spend while S is frozen.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
parent = tmp_path / "sess.jsonl"
parent.write_text("parent", encoding="utf-8")
# statusLine total (S) and transcript estimate (C) are both stubbed so
# the test can drive them independently across polls.
status_box = {"value": 0.01}
monkeypatch.setattr(
forwarder,
"read_claude_context_state",
lambda _bridge: {"total_cost_usd": status_box["value"]},
)
estimate_box = {"value": 0.65}
monkeypatch.setattr(
forwarder,
"_session_cost_estimate",
lambda **_kwargs: estimate_box["value"],
)
subagent_state = forwarder.SubagentForwardState(
subagents={
"aaa": forwarder.SubagentEntry(subagent_id="aaa", child_conversation_id="conv_child")
}
)
dedupe = forwarder._ForwardDedupeState()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
if body.get("type") == "external_session_usage":
posted.append(body["data"])
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
async def run() -> None:
await forwarder._forward_session_cost(
client=client,
session_id="conv_parent",
bridge_dir=bridge_dir,
parent_transcript_path=parent,
subagent_state=subagent_state,
dedupe=dedupe,
cost_cache={},
)
# First poll: display = S (0.01) verbatim, policy = max(0.01, 0.65).
# If display showed 0.65 here, the badge would diverge from /cost —
# the exact bug this split fixes.
await run()
assert posted == [
{
"cumulative_cost_usd": pytest.approx(0.01),
"policy_cost_usd": pytest.approx(0.65),
}
]
assert dedupe.posted_cost == pytest.approx(0.01)
assert dedupe.posted_policy_cost == pytest.approx(0.65)
# Nothing changed → neither field re-posts. A 2nd post would mean a
# dedupe baseline wasn't honored.
await run()
assert len(posted) == 1
# A lower transcript read must NOT walk policy back, and S is
# unchanged → no post at all (both fields monotonic).
estimate_box["value"] = 0.40
await run()
assert len(posted) == 1
# C advances while S stays frozen (sub-agent still running): only
# policy_cost_usd re-posts. Proves the badge (S) stays put mid-turn
# while the gate sees the rising in-flight cost.
estimate_box["value"] = 0.90
await run()
assert posted[-1] == {"policy_cost_usd": pytest.approx(0.90)}
assert dedupe.posted_policy_cost == pytest.approx(0.90)
# Display baseline untouched — S never advanced.
assert dedupe.posted_cost == pytest.approx(0.01)
# Turn settles: S jumps to the sub-agent-inclusive total. Display
# advances; policy advances to the same settled value. Both post.
status_box["value"] = 0.95
estimate_box["value"] = 0.95
await run()
assert posted[-1] == {
"cumulative_cost_usd": pytest.approx(0.95),
"policy_cost_usd": pytest.approx(0.95),
}
assert dedupe.posted_cost == pytest.approx(0.95)
assert dedupe.posted_policy_cost == pytest.approx(0.95)
@pytest.mark.asyncio
async def test_forward_session_cost_posts_status_when_no_subagents(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
With no sub-agents, display and policy are both the statusLine total.
There is no statusLine lag to correct without a sub-agent, so the
transcript estimator must not run and both ``cumulative_cost_usd``
(display) and ``policy_cost_usd`` (enforcement) equal S — they only
diverge while a sub-agent is mid-run.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
parent = tmp_path / "sess.jsonl"
parent.write_text("parent", encoding="utf-8")
monkeypatch.setattr(
forwarder, "read_claude_context_state", lambda _bridge: {"total_cost_usd": 0.25}
)
def _fail_estimate(**_kwargs: Any) -> float | None:
raise AssertionError("estimator must not run without sub-agents")
monkeypatch.setattr(forwarder, "_session_cost_estimate", _fail_estimate)
dedupe = forwarder._ForwardDedupeState()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
if body.get("type") == "external_session_usage":
posted.append(body["data"])
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
await forwarder._forward_session_cost(
client=client,
session_id="conv_parent",
bridge_dir=bridge_dir,
parent_transcript_path=parent,
subagent_state=forwarder.SubagentForwardState(subagents={}),
dedupe=dedupe,
cost_cache={},
)
# Both fields = S (0.25). policy_cost_usd present so the gate has a value
# without a sub-agent too; if it were missing, the engine would fall back
# to total_cost_usd (also S) — but the forwarder posts it explicitly.
assert posted == [
{
"cumulative_cost_usd": pytest.approx(0.25),
"policy_cost_usd": pytest.approx(0.25),
}
]
assert dedupe.posted_cost == pytest.approx(0.25)
assert dedupe.posted_policy_cost == pytest.approx(0.25)
@pytest.mark.asyncio
async def test_forward_session_cost_backs_off_after_rate_limit(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
parent = tmp_path / "sess.jsonl"
parent.write_text("parent", encoding="utf-8")
monkeypatch.setattr(
forwarder, "read_claude_context_state", lambda _bridge: {"total_cost_usd": 0.25}
)
now = {"value": 100.0}
monkeypatch.setattr(forwarder.time, "monotonic", lambda: now["value"])
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(429, headers={"retry-after": "5"}, request=request)
return httpx.Response(200, json={}, request=request)
dedupe = forwarder._ForwardDedupeState()
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
kwargs = {
"client": client,
"session_id": "conv_parent",
"bridge_dir": bridge_dir,
"parent_transcript_path": parent,
"subagent_state": forwarder.SubagentForwardState(subagents={}),
"dedupe": dedupe,
"cost_cache": {},
}
await forwarder._forward_session_cost(**kwargs)
assert calls == 1
assert dedupe.cost_retry_failures == 1
assert dedupe.cost_retry_not_before == pytest.approx(105.0)
await forwarder._forward_session_cost(**kwargs)
assert calls == 1
now["value"] = 105.0
await forwarder._forward_session_cost(**kwargs)
assert calls == 2
assert dedupe.cost_retry_failures == 0
assert dedupe.cost_retry_not_before == 0.0
assert dedupe.posted_cost == pytest.approx(0.25)
def test_parse_json_response_returns_value_on_valid_json() -> None:
"""
A normal JSON body parses through ``_parse_json_response`` unchanged.
:returns: None.
"""
resp = httpx.Response(200, json={"id": "conv_abc123"})
assert forwarder._parse_json_response(resp, context="session snapshot") == {
"id": "conv_abc123"
}
def test_parse_json_response_raises_diagnosable_error_on_html_body() -> None:
"""
An HTML body (e.g. an expired Databricks Apps OAuth login page served
with a 200) raises a ``RuntimeError`` naming the content type and a
body snippet, not an opaque ``json.JSONDecodeError``. The original
parser error is preserved as ``__cause__`` for debugging.
:returns: None.
"""
resp = httpx.Response(
200,
html="<!DOCTYPE html><html><body>Sign in to continue</body></html>",
)
with pytest.raises(RuntimeError) as excinfo:
forwarder._parse_json_response(resp, context="session 'conv_abc123' snapshot")
message = str(excinfo.value)
assert "session 'conv_abc123' snapshot" in message
assert "text/html" in message
assert "<!DOCTYPE html>" in message
assert isinstance(excinfo.value.__cause__, ValueError)
@pytest.mark.asyncio
async def test_fetch_session_snapshot_raises_diagnosable_error_on_html_body() -> None:
"""
``_fetch_session_snapshot`` surfaces a clear error when the Sessions
API returns a 200 HTML body instead of JSON — the failure mode behind
Claude Code's "Unrecognized token '<'" crash when an auth/proxy page is
served in place of the API response. Without the guard this raised a
bare ``json.JSONDecodeError`` that the forwarder supervisor turned into
a silent restart loop.
:returns: None.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
html="<!DOCTYPE html><html><body>Sign in to continue</body></html>",
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
with pytest.raises(RuntimeError) as excinfo:
await forwarder._fetch_session_snapshot(client, "conv_abc123")
message = str(excinfo.value)
assert "conv_abc123" in message
assert "text/html" in message
@pytest.mark.asyncio
async def test_forward_session_cost_tags_display_advance_with_model(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A display-cost (S) advance is tagged with the statusLine's active model.
claude-native sends no token counts with its cost, so the server has
nothing to attribute the cost to in the per-model TOKEN USAGE view without
a ``model`` tag — it would drop the cost from that view. The forwarder
rides the statusLine model (captured in context.json) on the payload
whenever the display cost advances. A policy-only mid-turn re-post (S
frozen, only the gate estimate C advancing) carries NO model: there is no
new display cost to attribute, so tagging it would be meaningless churn.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
parent = tmp_path / "sess.jsonl"
parent.write_text("parent", encoding="utf-8")
status_box = {"value": 0.01}
monkeypatch.setattr(
forwarder,
"read_claude_context_state",
lambda _bridge: {"total_cost_usd": status_box["value"], "model": "claude-opus-4-8"},
)
estimate_box = {"value": 0.65}
monkeypatch.setattr(
forwarder,
"_session_cost_estimate",
lambda **_kwargs: estimate_box["value"],
)
subagent_state = forwarder.SubagentForwardState(
subagents={
"aaa": forwarder.SubagentEntry(subagent_id="aaa", child_conversation_id="conv_child")
}
)
dedupe = forwarder._ForwardDedupeState()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
if body.get("type") == "external_session_usage":
posted.append(body["data"])
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
async def run() -> None:
await forwarder._forward_session_cost(
client=client,
session_id="conv_parent",
bridge_dir=bridge_dir,
parent_transcript_path=parent,
subagent_state=subagent_state,
dedupe=dedupe,
cost_cache={},
)
# Display cost advances → the model rides along for per-model attribution.
await run()
assert posted == [
{
"cumulative_cost_usd": pytest.approx(0.01),
"policy_cost_usd": pytest.approx(0.65),
"model": "claude-opus-4-8",
}
]
# Mid-turn: S frozen, only C (policy) advances → policy-only re-post
# carries NO model (no new display cost to attribute).
estimate_box["value"] = 0.90
await run()
assert posted[-1] == {"policy_cost_usd": pytest.approx(0.90)}
# ---------------------------------------------------------------------------
# _persist_native_compaction_item tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_persist_native_compaction_item_posts_compaction_event(tmp_path: Path) -> None:
"""
``_persist_native_compaction_item`` queries the latest item and posts a compaction event.
The function GETs ``/v1/sessions/{id}/items?limit=1&order=desc`` to
find the most recent persisted item, reads post-compaction messages
from the Claude session, then POSTs a ``compaction`` event using
that item's id as ``last_item_id`` and the messages as
``compacted_messages``.
"""
get_response = MagicMock()
get_response.raise_for_status = MagicMock()
get_response.json.return_value = {"data": [{"id": "item_123"}]}
post_response = MagicMock()
post_response.raise_for_status = MagicMock()
client = AsyncMock()
client.get.return_value = get_response
client.post.return_value = post_response
# Build a fake message returned by get_session_messages.
fake_msg = MagicMock()
fake_msg.type = "assistant"
fake_msg.message = {"content": [{"type": "text", "text": "hello"}]}
bridge_dir = tmp_path / "bridge"
with (
patch(
"omnigent.claude_native_forwarder.read_claude_session_id",
return_value="claude-uuid-1",
),
patch(
"claude_agent_sdk.get_session_messages",
return_value=[fake_msg],
),
):
await _persist_native_compaction_item(
client, session_id="conv_test", bridge_dir=bridge_dir
)
client.get.assert_called_once_with(
"/v1/sessions/conv_test/items",
params={"limit": 1, "order": "desc"},
)
client.post.assert_called_once()
post_call = client.post.call_args
assert post_call[0][0] == "/v1/sessions/conv_test/events"
body = post_call[1]["json"] if "json" in post_call[1] else post_call[0][1]
assert body["type"] == "compaction"
assert body["data"]["last_item_id"] == "item_123"
assert body["data"]["summary"] is not None
assert body["data"]["model"] == "unknown"
assert body["data"]["token_count"] == 0
# compacted_messages should contain the converted fake message.
assert body["data"]["compacted_messages"] == [
{"type": "message", "role": "assistant", "content": [{"type": "text", "text": "hello"}]},
]
@pytest.mark.asyncio
async def test_persist_native_compaction_item_empty_items_uses_fallback(tmp_path: Path) -> None:
"""
When no items exist, ``last_item_id`` falls back to a generated boundary id.
If the session has no persisted items yet (e.g. the very first turn
was compacted before anything was stored), the function generates
``compact_boundary_{session_id}`` as the boundary marker instead of
crashing on an empty list.
"""
get_response = MagicMock()
get_response.raise_for_status = MagicMock()
get_response.json.return_value = {"data": []}
post_response = MagicMock()
post_response.raise_for_status = MagicMock()
client = AsyncMock()
client.get.return_value = get_response
client.post.return_value = post_response
bridge_dir = tmp_path / "bridge"
with (
patch(
"omnigent.claude_native_forwarder.read_claude_session_id",
return_value=None,
),
):
await _persist_native_compaction_item(
client, session_id="conv_empty", bridge_dir=bridge_dir
)
post_call = client.post.call_args
body = post_call[1]["json"] if "json" in post_call[1] else post_call[0][1]
assert body["data"]["last_item_id"].startswith("compact_boundary_")
# No compacted_messages when claude_sid is None.
assert "compacted_messages" not in body["data"]
@pytest.mark.asyncio
async def test_compaction_completed_triggers_persist(tmp_path: Path) -> None:
"""
``SessionStart source=compact`` triggers both status POST and item persistence.
When the forwarder processes a ``SessionStart source=compact`` record
(compaction completed), it must call ``_post_external_compaction_status``
to surface the status AND ``_persist_native_compaction_item`` to write
the compaction boundary item.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
# Initial SessionStart populates transcript_path.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
# PreCompact mints the pending token the completion signal consumes.
# A real compaction always fires PreCompact before the compact
# SessionStart; the hook path only persists when that token exists.
record_hook_event(
bridge_dir,
{"hook_event_name": "PreCompact", "session_id": "claude-session"},
)
# Post-compaction SessionStart — the completion signal.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "compact",
"session_id": "claude-session",
},
)
server, thread, base_url = _start_recording_server()
persist_called = asyncio.Event()
async def _persist_side_effect(*args: Any, **kwargs: Any) -> None:
persist_called.set()
persist_mock = AsyncMock(side_effect=_persist_side_effect)
with patch(
"omnigent.claude_native_forwarder._persist_native_compaction_item",
persist_mock,
):
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_persist",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Wait for the compaction-completed status POST to arrive
# (the leading PreCompact in_progress edge is skipped).
request = None
for _ in range(10):
candidate = await _get_recorded_request(server)
if (
candidate["body"].get("type") == "external_compaction_status"
and candidate["body"]["data"].get("status") == "completed"
):
request = candidate
break
assert request is not None, "compaction-completed status was never posted"
# Wait for _persist_native_compaction_item to be called
# (it runs right after the POST in the same await chain).
await asyncio.wait_for(persist_called.wait(), timeout=5.0)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# The recording server captured the compaction-completed status POST.
assert request["body"]["type"] == "external_compaction_status"
assert request["body"]["data"]["status"] == "completed"
# _persist_native_compaction_item was called with the right session id.
persist_mock.assert_called_once()
call_kwargs = persist_mock.call_args
assert call_kwargs[1]["session_id"] == "conv_persist"
@pytest.mark.asyncio
async def test_compaction_in_progress_does_not_persist(tmp_path: Path) -> None:
"""
``PreCompact`` (in_progress) does NOT call ``_persist_native_compaction_item``.
Only compaction *completion* (``SessionStart source=compact``) writes
the boundary item. ``PreCompact`` merely forwards the ``in_progress``
status so the UI shows a spinner — there is no boundary to persist yet.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{"hook_event_name": "PreCompact", "session_id": "claude-session"},
)
server, thread, base_url = _start_recording_server()
persist_mock = AsyncMock()
with patch(
"omnigent.claude_native_forwarder._persist_native_compaction_item",
persist_mock,
):
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_no_persist",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Wait for the in_progress status POST to arrive.
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["body"]["type"] == "external_compaction_status"
assert request["body"]["data"]["status"] == "in_progress"
# _persist_native_compaction_item must NOT be called for in_progress.
persist_mock.assert_not_called()
# ---------------------------------------------------------------------------
# Durable compaction-boundary reconciliation (native resume/replay fix)
# ---------------------------------------------------------------------------
def _compact_summary_item(text: str = "compaction summary text") -> ClaudeTranscriptItem:
"""
Build a transcript item flagged as a Claude ``isCompactSummary`` record.
:param text: The continuation-summary text carried by the item.
:returns: A ``ClaudeTranscriptItem`` with ``is_compact_summary=True``.
"""
return ClaudeTranscriptItem(
source_id="summary-uuid:0:compact_summary",
item_type="message",
data={"role": "user", "content": [{"type": "input_text", "text": text}]},
response_id="resp_summary",
is_compact_summary=True,
)
def _persist_mock() -> AsyncMock:
"""
Build an ``AsyncMock`` standing in for ``_persist_native_compaction_item``.
:returns: An async mock that records calls and returns ``None``.
"""
return AsyncMock(return_value=None)
@pytest.mark.asyncio
async def test_missing_compact_session_start_still_persists_from_transcript(
tmp_path: Path,
) -> None:
"""
A transcript ``isCompactSummary`` record persists the boundary alone.
Reproduces the core bug: the flaky ``SessionStart source=compact`` hook
never fires, so only the transcript summary is available. The transcript
path must still persist exactly one compaction boundary (carrying the
summary text) once a ``PreCompact`` token is pending.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(
bridge_dir, claude_session_id="claude-1", transcript_path="/t/session.jsonl"
)
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_missing_hook",
bridge_dir=bridge_dir,
item=_compact_summary_item("the summary"),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
persist.assert_called_once()
assert persist.call_args[1]["session_id"] == "conv_missing_hook"
assert persist.call_args[1]["summary_override"] == "the summary"
# Boundary marked persisted; pending cleared.
state = _read_compaction_state(bridge_dir)
assert state.pending is None
assert 1 in state.persisted_seqs
@pytest.mark.asyncio
async def test_normal_hook_after_transcript_does_not_double_persist(tmp_path: Path) -> None:
"""
The completion hook does not re-persist a boundary the transcript wrote.
After the transcript path persists the boundary and marks the sequence
done, a later ``SessionStart source=compact`` hook finds no consumable
pending token, so ``_consume_pending_compaction`` returns ``None`` and no
second boundary is written.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
# Transcript path persists first.
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_dedupe",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert persist.call_count == 1
# Hook path arrives later — the token is already consumed.
seq = await _consume_pending_compaction(
bridge_dir, claude_session_id="claude-1", transcript_path=None
)
assert seq is None
@pytest.mark.asyncio
async def test_failed_boundary_post_is_retried_not_consumed(tmp_path: Path) -> None:
"""
A hard POST failure leaves the summary unconsumed for retry.
``_handle_compact_summary_item`` must return ``False`` (so the caller
holds the transcript cursor before the summary record) and must NOT mark
the sequence persisted, so the boundary is retried on a later poll rather
than silently lost — which would make resume reload the full history.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
# A definitively-permanent 400 (not an ambiguous/network failure).
request = httpx.Request("POST", "http://x/events")
response = httpx.Response(400, request=request)
failing = AsyncMock(
side_effect=httpx.HTTPStatusError("bad", request=request, response=response)
)
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", failing):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_retry",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert handled is False
state = _read_compaction_state(bridge_dir)
# Pending still set, nothing persisted — the summary will be retried.
assert state.pending is not None
assert state.pending.seq == 1
assert state.persisted_seqs == ()
@pytest.mark.asyncio
async def test_restart_reattach_does_not_repersist_completed_boundary(tmp_path: Path) -> None:
"""
An already-persisted boundary is never re-persisted after a rewind.
Simulates a process restart / cursor rewind that re-reads a summary whose
boundary already POSTed: ``persisted_seqs`` records the sequence, so
``_consume_pending_compaction`` returns ``None`` and
``_handle_compact_summary_item`` drops the record without persisting.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
# Durable state as it would exist after a completed compaction: seq 1
# persisted, but a stale pending token for the same seq lingers (e.g.
# crash between POST success and mark). The persisted set must win.
from omnigent.claude_native_forwarder import _PendingCompaction, _write_compaction_state
_write_compaction_state(
bridge_dir,
CompactionForwardState(
pending=_PendingCompaction(seq=1, claude_session_id="claude-1"),
last_seq=1,
persisted_seqs=(1,),
),
)
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_restart",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
persist.assert_not_called()
@pytest.mark.asyncio
async def test_repeated_compactions_persist_distinct_boundaries(tmp_path: Path) -> None:
"""
Two compaction cycles persist two distinct boundaries.
Each ``PreCompact`` mints a fresh monotonic sequence, so a second
compaction is not blocked by the first's ``persisted_seqs`` entry.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
# First compaction.
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_repeat",
bridge_dir=bridge_dir,
item=_compact_summary_item("first"),
retry_tracker=_PostRetryTracker(),
)
# Second compaction, later in the same session.
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_repeat",
bridge_dir=bridge_dir,
item=_compact_summary_item("second"),
retry_tracker=_PostRetryTracker(),
)
assert persist.call_count == 2
state = _read_compaction_state(bridge_dir)
assert state.pending is None
assert set(state.persisted_seqs) == {1, 2}
@pytest.mark.asyncio
async def test_historical_summary_without_pending_is_skipped(tmp_path: Path) -> None:
"""
An ``isCompactSummary`` record with no pending PreCompact is dropped.
On a cold resume the transcript may contain a historical compact-summary
record from a prior compaction with no live ``PreCompact`` token. It must
not persist a spurious boundary, and must not be forwarded as a user
bubble — ``_handle_compact_summary_item`` returns handled with no persist.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir() # no _note_precompact — no pending token
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_historical",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
persist.assert_not_called()
assert _read_compaction_state(bridge_dir).persisted_seqs == ()
@pytest.mark.asyncio
async def test_ambiguous_boundary_post_marks_persisted(tmp_path: Path) -> None:
"""
An ambiguous POST failure is treated as delivered (no duplicate boundary).
Mirrors the item-forwarding rule: when the server may already have
committed the boundary (e.g. a dropped response on a 2xx), retrying would
risk a duplicate compaction bubble, so the sequence is marked persisted
and the record advanced.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
ambiguous = AsyncMock(side_effect=httpx.ReadError("connection dropped mid-response"))
with (
patch("omnigent.claude_native_forwarder._persist_native_compaction_item", ambiguous),
patch("omnigent.claude_native_forwarder.post_may_have_been_delivered", return_value=True),
):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_ambiguous",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
state = _read_compaction_state(bridge_dir)
assert 1 in state.persisted_seqs
assert state.pending is None
@pytest.mark.asyncio
async def test_precompact_and_summary_same_poll_persists_boundary(tmp_path: Path) -> None:
"""
P1-1: a PreCompact + summary first visible in one poll persists a boundary.
The transcript forwarder (which consumes the ``isCompactSummary`` record)
runs before the hook forwarder (which mints the ``PreCompact`` token)
within a single poll. Without the pre-items prescan, a ``PreCompact`` and
its summary that both first appear in the same poll would lose the
boundary — the summary is consumed with no token yet minted.
``_prescan_precompact_edges`` mints the token first, so the summary that
follows in the same poll finds it.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
# A PreCompact hook is written but the hook cursor has NOT advanced past
# it yet (mirrors the same-poll ordering: hooks are forwarded AFTER items).
record_hook_event(
bridge_dir,
{"hook_event_name": "PreCompact", "session_id": "claude-1"},
)
hook_state = await forwarder._ensure_hook_state(
bridge_dir, start_at_end=False, session_id="conv_same_poll"
)
# No pending token before the prescan.
assert _read_compaction_state(bridge_dir).pending is None
# Prescan mints the token BEFORE the transcript summary is processed.
await _prescan_precompact_edges(bridge_dir, hook_state)
state = _read_compaction_state(bridge_dir)
assert state.pending is not None
assert state.pending.seq == 1
# The summary in the same poll now finds the token and persists once.
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_same_poll",
bridge_dir=bridge_dir,
item=_compact_summary_item("same-poll summary"),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
persist.assert_called_once()
state = _read_compaction_state(bridge_dir)
assert 1 in state.persisted_seqs
assert state.pending is None
@pytest.mark.asyncio
async def test_prescan_is_idempotent_with_hook_phase(tmp_path: Path) -> None:
"""
P1-1: the prescan and the main hook phase mint one token per PreCompact.
Both scans see the same ``PreCompact`` record each poll. The
``event_cursor`` idempotency key must keep them converging on a single
pending token — never two — so a re-mint cannot overwrite a token whose
boundary is mid-persist.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
record_hook_event(
bridge_dir,
{"hook_event_name": "PreCompact", "session_id": "claude-1"},
)
hook_state = await forwarder._ensure_hook_state(
bridge_dir, start_at_end=False, session_id="conv_idem"
)
# Prescan mints seq 1.
await _prescan_precompact_edges(bridge_dir, hook_state)
first = _read_compaction_state(bridge_dir)
assert first.pending is not None and first.pending.seq == 1
assert first.last_precompact_cursor == 1
# The main hook phase would note the SAME edge (same event_cursor=1).
# It must be a no-op: same seq, no second token.
await _note_precompact(
bridge_dir, claude_session_id="claude-1", transcript_path=None, event_cursor=1
)
second = _read_compaction_state(bridge_dir)
assert second.pending is not None and second.pending.seq == 1
assert second.last_seq == 1
# A genuinely NEW PreCompact edge (higher cursor) mints the next seq.
await _note_precompact(
bridge_dir, claude_session_id="claude-1", transcript_path=None, event_cursor=2
)
third = _read_compaction_state(bridge_dir)
assert third.pending is not None and third.pending.seq == 2
assert third.last_precompact_cursor == 2
@pytest.mark.asyncio
async def test_standalone_completion_hook_persists_without_pending(tmp_path: Path) -> None:
"""
P1-2: a compact SessionStart with no pending token still persists a boundary.
Restores the legacy standalone-completion safety. When the
``PreCompact`` hook was dropped (or the forwarder attached after it
fired) AND no transcript summary has persisted a boundary, the
``SessionStart source=compact`` completion hook must still persist
exactly one boundary — otherwise resume reloads the full pre-compaction
history. ``_claim_standalone_completion`` mints the sequence for it.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
# No _note_precompact, no persisted boundary — genuinely standalone.
seq = await _claim_standalone_completion(bridge_dir)
assert seq == 1
state = _read_compaction_state(bridge_dir)
# A pending token is installed so a later transcript summary reconciles
# against the same sequence instead of double-persisting.
assert state.pending is not None
assert state.pending.seq == 1
# After the caller persists and marks it done, the boundary is recorded.
from omnigent.claude_native_forwarder import _mark_compaction_persisted
await _mark_compaction_persisted(bridge_dir, seq)
final = _read_compaction_state(bridge_dir)
assert 1 in final.persisted_seqs
assert final.pending is None
@pytest.mark.asyncio
async def test_completion_hook_after_transcript_persist_is_absorbed(tmp_path: Path) -> None:
"""
P1-2: a completion hook trailing a transcript-persisted boundary is absorbed.
The transcript ``isCompactSummary`` path and the
``SessionStart source=compact`` hook are two completion signals for the
SAME compaction. When the transcript path persists first it arms the
completion-ack window; the trailing hook must be absorbed (return
``None``, no new sequence) rather than persist a spurious standalone
boundary.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
# Transcript path persists the boundary; arms expect_completion_ack.
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_absorb",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert persist.call_count == 1
armed = _read_compaction_state(bridge_dir)
assert armed.expect_completion_ack is True
# The trailing completion hook finds no pending token and is absorbed.
seq = await _consume_pending_compaction(
bridge_dir, claude_session_id="claude-1", transcript_path=None
)
assert seq is None
seq = await _claim_standalone_completion(bridge_dir)
assert seq is None # absorbed, NOT a new standalone boundary
after = _read_compaction_state(bridge_dir)
assert after.expect_completion_ack is False
assert after.persisted_seqs == (1,) # still exactly one boundary
@pytest.mark.asyncio
async def test_precompact_miss_is_counted_and_warned(tmp_path: Path) -> None:
"""
P1-3: a summary skipped with no token and no boundary is counted as a miss.
A skipped ``isCompactSummary`` with no pending token AND no boundary ever
persisted is the observable ``PreCompact``-miss failure mode — it must
bump the ``precompact_miss`` counter (not be silently dropped). A skip
that follows a persisted boundary is an expected replay/dedupe and bumps
``expected_skip`` instead.
"""
_reset_compaction_skip_stats()
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir() # no PreCompact, no persisted boundary
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
handled = await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_miss",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert handled is True
persist.assert_not_called()
assert forwarder._compaction_skip_stats.precompact_miss == 1
assert forwarder._compaction_skip_stats.expected_skip == 0
# A skip AFTER a boundary was persisted is an expected replay, not a miss.
from omnigent.claude_native_forwarder import _write_compaction_state
_write_compaction_state(
bridge_dir,
CompactionForwardState(pending=None, last_seq=1, persisted_seqs=(1,)),
)
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_miss",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
assert forwarder._compaction_skip_stats.precompact_miss == 1 # unchanged
assert forwarder._compaction_skip_stats.expected_skip == 1
@pytest.mark.asyncio
async def test_stale_completion_ack_does_not_swallow_a_later_boundary(
tmp_path: Path,
) -> None:
"""
P2-1: a completion ack is bound to its seq and is one-shot per boundary.
The lost-boundary hazard: compaction A persists via the transcript path
and arms ``expect_completion_ack``; A's own ``SessionStart source=compact``
hook never fires (flaky), so the flag stays armed. A later compaction B's
``PreCompact`` is *also* dropped, then B's completion hook fires. With a
bare unattributed flag, B's hook would be absorbed as A's stale ack and
B's boundary lost.
Binding the ack to a ``seq`` and making absorption one-shot fixes it: the
window is consumed exactly once (the trailing hook for A), and any
*further* standalone completion — B's — falls through to a fresh persist
instead of being swallowed.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
await _note_precompact(bridge_dir, claude_session_id="claude-1", transcript_path=None)
persist = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", persist):
# Compaction A persists via the transcript path → arms the ack for A's seq.
await _handle_compact_summary_item(
AsyncMock(),
session_id="conv_p21",
bridge_dir=bridge_dir,
item=_compact_summary_item(),
retry_tracker=_PostRetryTracker(),
)
armed = _read_compaction_state(bridge_dir)
assert armed.expect_completion_ack is True
assert armed.expect_completion_ack_seq == 1 # bound to A's seq, not a bare bool
assert armed.persisted_seqs == (1,)
# A's own trailing completion hook arrives late and is absorbed (one-shot).
absorbed = await _claim_standalone_completion(bridge_dir)
assert absorbed is None
after_absorb = _read_compaction_state(bridge_dir)
assert after_absorb.expect_completion_ack is False
assert after_absorb.expect_completion_ack_seq == 0 # window closed
# Compaction B: its PreCompact was dropped too, so B arrives as a
# standalone completion hook with NO pending token and NO armed ack. It
# must persist a fresh boundary, not be swallowed as A's stale ack.
b_seq = await _claim_standalone_completion(bridge_dir)
assert b_seq == 2, "B's boundary must be persisted, not lost to a stale ack"
final = _read_compaction_state(bridge_dir)
assert final.pending is not None
assert final.pending.seq == 2
@pytest.mark.asyncio
async def test_completion_ack_armed_for_unpersisted_seq_biases_to_persist(
tmp_path: Path,
) -> None:
"""
P2-1: an ack armed for a seq that is NOT persisted persists (bias-to-safe).
If durable state is somehow armed (corrupt/partial write, or a legacy
``compaction_forwarder.json`` from before ``expect_completion_ack_seq``
existed so the seq reads back as ``0``) the standalone path cannot prove
the arriving hook is a duplicate. A lost boundary reloads the full
pre-compaction history on resume — far worse than an at-most-once
duplicate — so the path biases to persisting a fresh boundary rather than
silently absorbing the hook.
"""
from omnigent.claude_native_forwarder import _write_compaction_state
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
# Legacy/corrupt shape: flag armed but the seq it points at is not in
# persisted_seqs (here it reads back as 0, mimicking an old state file).
_write_compaction_state(
bridge_dir,
CompactionForwardState(
pending=None,
last_seq=1,
persisted_seqs=(),
expect_completion_ack=True,
expect_completion_ack_seq=0,
),
)
seq = await _claim_standalone_completion(bridge_dir)
assert seq == 2, "bias-to-safe: persist rather than absorb an unprovable ack"
state = _read_compaction_state(bridge_dir)
assert state.pending is not None
assert state.pending.seq == 2
@pytest.mark.asyncio
async def test_standalone_hook_persist_failure_holds_cursor_for_retry(
tmp_path: Path,
) -> None:
"""
P2-2: a standalone-completion persist failure holds the hook cursor.
A ``SessionStart source=compact`` with no pending token and no transcript
summary is a hook-only standalone compaction. If its boundary POST fails
transiently the forwarder must NOT advance past the hook (losing the
boundary, since no transcript summary will ever retry it) — it holds the
hook cursor and retries next poll, mirroring the transcript path. The
pending token minted by ``_claim_standalone_completion`` makes the retry
idempotent: the re-seen hook re-consumes the same seq. A later successful
POST persists exactly one boundary.
"""
bridge_dir = tmp_path / "bridge"
# A lone compact SessionStart (no preceding PreCompact) — standalone.
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"source": "compact",
"session_id": "claude-standalone",
},
)
start_state = forwarder.HookForwardState(event_cursor=0, byte_offset=0)
request = httpx.Request("POST", "http://test/items")
response = httpx.Response(503, request=request)
failing = AsyncMock(
side_effect=httpx.HTTPStatusError("boom", request=request, response=response)
)
async def _run_once(state: forwarder.HookForwardState) -> forwarder.HookForwardState:
# The best-effort spinner status post is orthogonal to the durable
# persist under test; stub it so the client mock stays quiet.
with patch(
"omnigent.claude_native_forwarder._post_external_compaction_status",
AsyncMock(return_value=None),
):
return await forwarder._forward_available_status_events(
client=AsyncMock(),
session_id="conv_p22",
bridge_dir=bridge_dir,
state=state,
retry_tracker=_PostRetryTracker(),
dedupe=forwarder._ForwardDedupeState(),
task_subjects={},
task_statuses={},
task_order=[],
)
# Poll 1: persist fails → cursor is held BEFORE the compaction hook record,
# a pending token is minted, and no boundary is marked persisted.
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", failing):
after_fail = await _run_once(start_state)
assert failing.await_count == 1
assert after_fail.event_cursor == start_state.event_cursor # cursor held
held = _read_compaction_state(bridge_dir)
assert held.pending is not None # token minted, awaiting a durable persist
assert not held.persisted_seqs # nothing marked persisted on failure
minted_seq = held.pending.seq
# Poll 2 (retry): the same hook record is re-seen; the persist succeeds and
# re-consumes the SAME seq (idempotent), marking exactly one boundary.
ok = _persist_mock()
with patch("omnigent.claude_native_forwarder._persist_native_compaction_item", ok):
after_ok = await _run_once(after_fail)
assert ok.await_count == 1
persisted = _read_compaction_state(bridge_dir)
assert persisted.persisted_seqs == (minted_seq,) # exactly one boundary
assert persisted.pending is None
assert after_ok.event_cursor > after_fail.event_cursor # cursor advanced
def test_forward_failures_escalate_to_degraded_once() -> None:
"""
Sustained forward failures flip the degraded latch exactly once (#1120).
Network drops previously surfaced only as scattered per-item warnings;
the latch turns a real outage into a single loud signal and does not
re-fire per dropped item.
"""
forwarder._reset_forward_health()
for _ in range(forwarder._FORWARD_DEGRADED_THRESHOLD - 1):
forwarder._note_forward_failure("item:source-1")
# Below threshold: not yet degraded.
assert forwarder._forward_health.degraded_logged is False
forwarder._note_forward_failure("item:source-1") # crosses threshold
assert forwarder._forward_health.degraded_logged is True
assert forwarder._forward_health.consecutive_failures == forwarder._FORWARD_DEGRADED_THRESHOLD
# The latch holds — further failures keep counting but don't re-escalate.
forwarder._note_forward_failure("item:source-1")
assert forwarder._forward_health.degraded_logged is True
assert (
forwarder._forward_health.consecutive_failures == forwarder._FORWARD_DEGRADED_THRESHOLD + 1
)
def test_forward_success_resets_degraded_state() -> None:
"""
A successful forward clears the failure count and degraded latch.
Recovery must re-arm the indicator so a later outage escalates again.
"""
forwarder._reset_forward_health()
for _ in range(forwarder._FORWARD_DEGRADED_THRESHOLD):
forwarder._note_forward_failure("status:idle")
assert forwarder._forward_health.degraded_logged is True
forwarder._note_forward_success()
assert forwarder._forward_health.consecutive_failures == 0
assert forwarder._forward_health.degraded_logged is False
def test_retry_tracker_transient_failures_escalate_degraded() -> None:
"""
Transient failures escalate via the retry tracker boundary (#1120).
The claude forwarder retries transient errors (connect timeouts, 503s)
forever, so they never reach the permanent-drop ``exhausted`` path. This
proves the degraded indicator still fires for that case — the exact
503/connect-timeout outage #1120 is about — because every
``record_failure`` counts, not just exhausted give-ups. A later
``clear`` (a post that got through) re-arms the indicator.
"""
forwarder._reset_forward_health()
tracker = forwarder._PostRetryTracker()
transient = httpx.ConnectError("connect timeout")
for _ in range(forwarder._FORWARD_DEGRADED_THRESHOLD):
decision = tracker.record_failure("item:source-1", transient)
# Transient failures are retried, never dropped.
assert decision.exhausted is False
assert decision.permanent is False
assert forwarder._forward_health.degraded_logged is True
tracker.clear("item:source-1")
assert forwarder._forward_health.consecutive_failures == 0
assert forwarder._forward_health.degraded_logged is False
@pytest.mark.asyncio
async def test_subagent_item_drop_writes_dead_letter(tmp_path: Path) -> None:
"""
A permanently-rejected sub-agent transcript item is dead-lettered (#1120).
Drives the real ``_forward_available_subagents`` drop path: the
``external_subagent_start`` POST succeeds, the child item POST is rejected
with a permanent 400 (and the item tracker exhausts on the first failure),
so the dropped item is appended to ``{bridge_dir}/dead_letter.jsonl`` instead
of being silently lost.
:param tmp_path: Pytest temp dir for the bridge dir and transcript.
"""
forwarder._reset_forward_health()
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="dl1",
agent_type="Explore",
description="dead-letter item flow",
tool_use_id="toolu_dl",
transcript_records=[
{
"isSidechain": True,
"type": "assistant",
"uuid": "sa-assistant-dl",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "lost"}],
},
},
],
)
def handler(request: httpx.Request) -> httpx.Response:
"""Accept the start POST; permanently reject the child item POST.
:param request: Request issued by the forwarder.
:returns: Canned Omnigent response.
"""
body = json.loads(request.content.decode("utf-8"))
if body.get("type") == "external_subagent_start":
return httpx.Response(200, json={"child_session_id": "conv_child_dl"})
if body.get("type") == "external_conversation_item":
return httpx.Response(400, json={"error": "nope"})
return httpx.Response(202, json={})
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="http://ap",
) as client:
await forwarder._forward_available_subagents(
client=client,
parent_session_id="conv_parent",
bridge_dir=bridge_dir,
transcript_path=transcript_path,
state=forwarder.SubagentForwardState(subagents={}),
agent_name="claude-native-ui",
start_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
item_retry_tracker=forwarder._PostRetryTracker(
base_delay_s=0.0, max_permanent_attempts=1
),
status_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
)
forwarder._reset_forward_health()
dl_path = bridge_dir / "dead_letter.jsonl"
lines = dl_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1
record = json.loads(lines[0])
assert record["session_id"] == "conv_child_dl"
assert record["event_type"] == "external_conversation_item"
assert record["payload"]["item_data"]["content"][0]["text"] == "lost"
@pytest.mark.asyncio
async def test_subagent_start_drop_writes_dead_letter(tmp_path: Path) -> None:
"""
A permanently-rejected sub-agent START is dead-lettered (#1120).
:param tmp_path: Pytest temp dir for the bridge dir and transcript.
"""
forwarder._reset_forward_health()
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
_seed_subagent_on_disk(
transcript_path=transcript_path,
subagent_id="dlstart1",
agent_type="Explore",
description="dead-letter start flow",
tool_use_id="toolu_dlstart",
)
def handler(request: httpx.Request) -> httpx.Response:
"""Permanently reject the sub-agent start POST.
:param request: Request issued by the forwarder.
:returns: Canned Omnigent response.
"""
return httpx.Response(400, json={"error": "nope"})
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="http://ap",
) as client:
await forwarder._forward_available_subagents(
client=client,
parent_session_id="conv_parent",
bridge_dir=bridge_dir,
transcript_path=transcript_path,
state=forwarder.SubagentForwardState(subagents={}),
agent_name="claude-native-ui",
start_retry_tracker=forwarder._PostRetryTracker(
base_delay_s=0.0, max_permanent_attempts=1
),
item_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
status_retry_tracker=forwarder._PostRetryTracker(base_delay_s=0.0),
)
forwarder._reset_forward_health()
dl_path = bridge_dir / "dead_letter.jsonl"
lines = dl_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1
record = json.loads(lines[0])
assert record["session_id"] == "conv_parent"
assert record["event_type"] == "external_subagent_start"
assert record["payload"]["subagent_id"] == "dlstart1"
assert record["payload"]["agent_type"] == "Explore"
@pytest.mark.asyncio
async def test_forwarder_posts_idle_with_count_when_stop_has_background_tasks(
tmp_path: Path,
) -> None:
"""
``Stop`` with ``background_tasks`` posts ``idle`` plus the shell count.
The turn really has ended, so the status is ``idle`` — the spinner stays
lit off the count instead (``showsWorking`` is ``isWorking || tally > 0``).
The count is the one thing Claude's status file cannot report: its
``shell`` literal is a boolean and the indicator renders a number, which
is why this hook still posts at all.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text("", encoding="utf-8")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "claude-session",
"background_tasks": [
{
"id": "abc123",
"type": "shell",
"status": "running",
"description": "Wait for CI",
"command": "sleep 120",
},
],
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
request = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
assert request["path"] == "/v1/sessions/conv_abc/events"
assert request["body"] == {
"type": "external_session_status",
"data": {"status": "idle", "background_task_count": 1},
}
@pytest.mark.asyncio
async def test_post_external_session_status_includes_and_omits_response_id() -> None:
"""
``post_external_session_status`` attaches ``response_id`` only when given.
The turn-bearing edges (native Claude's turn start/end) carry the response
id so ap-web can drive the bubble's streaming lifecycle; the bare,
turn-agnostic edges (e.g. the sub-agent quiescence badge) must keep posting
a ``data`` object with no ``response_id`` key so nothing spuriously matches.
"""
bodies: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
bodies.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
await forwarder.post_external_session_status(
client, session_id="conv_abc", status="running", response_id="resp_1"
)
await forwarder.post_external_session_status(client, session_id="conv_abc", status="idle")
assert bodies[0] == {
"type": "external_session_status",
"data": {"status": "running", "response_id": "resp_1"},
}
# Bare edge: no response_id key (not a null) so the server's optional
# validation passes and the client never opens a streaming response.
assert bodies[1] == {
"type": "external_session_status",
"data": {"status": "idle"},
}
@pytest.mark.asyncio
async def test_forward_status_events_stamps_response_id_on_idle(tmp_path: Path) -> None:
"""
A ``Stop`` → idle edge carries the turn's ``response_id`` when one is known.
This is what closes the streaming ``activeResponse`` ap-web opened from the
turn-start ``running`` edge, so the trailing tool card stops spinning.
"""
bridge_dir = tmp_path / "bridge"
record_hook_event(
bridge_dir,
{"hook_event_name": "Stop", "session_id": "claude-session"},
)
bodies: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
bodies.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
dedupe = forwarder._ForwardDedupeState()
async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client:
hook_state = await forwarder._ensure_hook_state(
bridge_dir, start_at_end=False, session_id="conv_abc"
)
await forwarder._forward_available_status_events(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
state=hook_state,
retry_tracker=forwarder._PostRetryTracker(),
dedupe=dedupe,
task_subjects={},
task_statuses={},
task_order=[],
response_id="resp_turn_1",
)
# The posted turn-end edge records the turn as a PENDING settle for
# scheduled-wake detection (it activates once the transcript is quiet).
assert dedupe.pending_settled_response_id == "resp_turn_1"
assert dedupe.settled_response_id is None
assert bodies == [
{
"type": "external_session_status",
# The Stop→idle edge carries the turn's response id AND the
# background-shell tally (0 here — no shells); the live-tool-card
# and background-task features share this one status edge.
"data": {
"status": "idle",
"background_task_count": 0,
"response_id": "resp_turn_1",
},
}
]
@pytest.mark.asyncio
async def test_forwarder_publishes_no_status_for_assistant_output(tmp_path: Path) -> None:
"""
Assistant output forwards items and publishes NO session status.
Claude's ``sessions/<pid>.json`` owns the running/idle badge. A status edge
derived from the transcript can only fire once a poll has parsed assistant
output, so on a short turn it lands *after* the file's ``idle`` and
re-asserts ``running`` on a session that already finished — the user sees
idle → running → idle. The items still carry their own ``response_id``.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "user-1",
"message": {"role": "user", "content": "read TODO"},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-tool-1",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_read_1",
"name": "Read",
"input": {"file_path": "TODO.md"},
}
],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
server, thread, base_url = _start_recording_server()
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url=base_url,
headers={},
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
# Both POSTs of the poll are items — no status edge precedes them.
item_a = await _get_recorded_request(server)
item_b = await _get_recorded_request(server)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
# Neither POST is a status edge — the transcript path publishes none.
assert [body["body"]["type"] for body in (item_a, item_b)] == [
"external_conversation_item",
"external_conversation_item",
]
# The assistant turn's item still carries its own response id, which is
# what groups its bubble and its tool cards on the client.
function_call = next(
body for body in (item_a, item_b) if body["body"]["data"]["item_type"] == "function_call"
)
rid = function_call["body"]["data"]["response_id"]
assert isinstance(rid, str) and rid
@pytest.mark.asyncio
async def test_short_turn_poll_posts_items_without_a_status_edge(tmp_path: Path) -> None:
"""
Regression: a short turn's poll must not re-assert ``running``.
The status file reports the turn ending the moment Claude settles, but a
transcript-derived edge can only fire once a poll has parsed assistant
output — so it arrived *after* that ``idle`` and flipped the session back to
``running``, then ``Stop`` closed it again: the user saw
idle → running → idle on every short turn.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "u1",
"message": {"role": "user", "content": "i'll keep testing"},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "a1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Sounds good."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
posted: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
"""
Record every forwarder POST body.
:param request: Outbound HTTP request from the forwarder.
:returns: HTTP 202 for the mock Omnigent endpoint.
"""
posted.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=forwarder._PostRetryTracker(),
dedupe=forwarder._ForwardDedupeState(),
)
assert [body["type"] for body in posted] == ["external_conversation_item"] * 2
assert not [body for body in posted if body["type"] == "external_session_status"]
@pytest.mark.asyncio
async def test_forwarder_does_not_leave_running_open_for_slash_command_only_turn(
tmp_path: Path,
) -> None:
"""
A ``/model``-only turn must not leave an id-bearing ``running`` dangling.
Surfaced CLI built-ins (``/model``, ``/effort``, ...) become a
``slash_command`` item that opens its OWN response id but produce no LLM
turn — so no ``Stop`` hook ever fires to close it. The forwarder's
turn-start edge still publishes ``running`` + that id, which opens a
streaming ``activeResponse`` in the web UI. Because the web store
suppresses the trailing bare (id-less) PTY ``idle`` while a response is
streaming, nothing clears it: the composer's Stop button stays lit and
the session looks busy even though the terminal is free.
The invariant: a poll that forwards only a slash-command item (no
assistant output) must either skip the id-bearing ``running`` edge or
emit a matching ``idle``/``failed`` carrying the same id, so the turn's
lifecycle closes.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "prior-assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Earlier reply."}],
},
}
),
json.dumps(
{
"type": "user",
"uuid": "slash-model",
"message": {
"role": "user",
"content": (
"<command-name>/model</command-name>\n"
" <command-message>model</command-message>\n"
" <command-args>opus</command-args>"
),
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
state = forwarder.TranscriptForwardState(
transcript_path=transcript_path,
line_cursor=0,
byte_offset=0,
cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0),
)
retry_tracker = forwarder._PostRetryTracker(
max_permanent_attempts=2,
base_delay_s=0.0,
max_delay_s=0.0,
)
requests: list[dict[str, Any]] = []
def _handle_request(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content.decode("utf-8"))
assert isinstance(payload, dict)
requests.append(payload)
return httpx.Response(202, json={})
transport = httpx.MockTransport(_handle_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
dedupe = forwarder._ForwardDedupeState()
await forwarder._forward_available_items(
client=client,
session_id="conv_abc",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
state=state,
retry_tracker=retry_tracker,
dedupe=dedupe,
)
statuses = [
request["data"] for request in requests if request["type"] == "external_session_status"
]
running_ids = {
status.get("response_id")
for status in statuses
if status["status"] == "running" and status.get("response_id") is not None
}
closed_ids = {
status.get("response_id") for status in statuses if status["status"] in ("idle", "failed")
}
# Any id-bearing ``running`` opened for the slash-command-only turn must
# be closed within the same poll — otherwise the web UI is stuck busy
# until the next real message. (No LLM turn means no later Stop hook.)
dangling = running_ids - closed_ids
assert not dangling, (
"slash-command-only turn left an id-bearing running status open with "
f"no matching idle/failed: {dangling}"
)
# Stronger: the forwarder opens NO id-bearing running for this turn at all
# (there is no assistant output to render live, so nothing to stream).
assert running_ids == set()
# The slash_command item itself still forwards — the switch stays visible
# in the web transcript; only the phantom ``running`` edge is suppressed.
forwarded = [
request["data"] for request in requests if request["type"] == "external_conversation_item"
]
assert any(item["item_type"] == "slash_command" for item in forwarded)
# _PostRetryTracker: bounded subagent_delivery_not_confirmed retries (L2)
# ---------------------------------------------------------------------------
def _http_status_error(status_code: int, body: object) -> httpx.HTTPStatusError:
"""Build an httpx.HTTPStatusError whose response.json() returns `body`."""
request = httpx.Request("POST", "http://omnigent/v1/sessions/conv_x/events")
content = json.dumps(body).encode() if body is not None else b""
response = httpx.Response(status_code, request=request, content=content)
return httpx.HTTPStatusError("rejected", request=request, response=response)
def test_subagent_delivery_not_confirmed_503_exhausts_after_budget() -> None:
tracker = forwarder._PostRetryTracker(max_not_confirmed_attempts=3)
exc = _http_status_error(
503, {"error": "subagent_delivery_not_confirmed", "reason": "missing_work_entry"}
)
# Attempts 1 and 2 keep retrying...
assert tracker.record_failure("k", exc).exhausted is False
assert tracker.record_failure("k", exc).exhausted is False
# ...attempt 3 hits the not-confirmed budget and gives up.
assert tracker.record_failure("k", exc).exhausted is True
def test_generic_503_without_not_confirmed_body_never_exhausts() -> None:
tracker = forwarder._PostRetryTracker(max_not_confirmed_attempts=3)
exc = _http_status_error(503, {"error": "internal_error"})
for _ in range(10):
assert tracker.record_failure("k", exc).exhausted is False
def test_permanent_4xx_still_exhausts_at_three() -> None:
tracker = forwarder._PostRetryTracker(max_permanent_attempts=3)
exc = _http_status_error(400, {"error": "bad_request"})
assert tracker.record_failure("k", exc).exhausted is False
assert tracker.record_failure("k", exc).exhausted is False
assert tracker.record_failure("k", exc).exhausted is True
def test_is_subagent_delivery_not_confirmed_classifier() -> None:
yes = _http_status_error(503, {"error": "subagent_delivery_not_confirmed"})
no_status = _http_status_error(500, {"error": "subagent_delivery_not_confirmed"})
no_body = _http_status_error(503, {"error": "something_else"})
assert forwarder._is_subagent_delivery_not_confirmed(yes) is True
assert forwarder._is_subagent_delivery_not_confirmed(no_status) is False
assert forwarder._is_subagent_delivery_not_confirmed(no_body) is False
assert forwarder._is_subagent_delivery_not_confirmed(httpx.ConnectError("boom")) is False
@pytest.mark.asyncio
async def test_forward_loop_deadline_unsticks_a_stalled_iteration(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A stalled await inside one poll iteration is cancelled and the loop resumes.
A silent stall in any forwarding stage used to stop mirroring, status
events and the pane busy signal forever — with zero log output — and
the pane reaper then killed the live session an hour later. The
iteration deadline converts such a stall into a logged, bounded
hiccup: the stuck await is cancelled (the warning's traceback names
it) and the next iteration proceeds.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
monkeypatch.setattr(forwarder, "_FORWARD_LOOP_STALL_DEADLINE_S", 0.2)
ensure_calls: list[int] = []
real_ensure = forwarder._ensure_hook_state
async def _stalls_on_first_call(*args: Any, **kwargs: Any) -> Any:
ensure_calls.append(len(ensure_calls) + 1)
if len(ensure_calls) == 1:
await asyncio.Event().wait()
return await real_ensure(*args, **kwargs)
monkeypatch.setattr(forwarder, "_ensure_hook_state", _stalls_on_first_call)
with caplog.at_level(logging.WARNING, logger="omnigent.claude_native_forwarder"):
task = asyncio.create_task(
forward_claude_transcript_to_session(
base_url="http://127.0.0.1:9",
headers={},
session_id="conv_stall",
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=False,
poll_interval_s=0.01,
)
)
try:
async def _second_iteration_ran() -> None:
while len(ensure_calls) < 2:
await asyncio.sleep(0.01)
await asyncio.wait_for(_second_iteration_ran(), timeout=5.0)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stall_warnings = [r for r in caplog.records if "iteration exceeded" in r.getMessage()]
assert stall_warnings, "the deadline trip must be loudly logged, never silent"
# The warning's traceback names the stalled await for next-time forensics.
assert stall_warnings[0].exc_info is not None