Files
omnigent-ai--omnigent/tests/test_claude_native_bridge.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

8487 lines
308 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 Code bridge helpers."""
from __future__ import annotations
import asyncio
import json
import os
import queue
import select
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from typing import Any, TextIO
import pytest
from omnigent import claude_native_bridge, native_cost_popup
from omnigent.claude_native_bridge import (
_build_tools,
_claude_prompt_rendered,
_escape_unsupported_slash_command,
_hook_record_from_jsonl_record,
_JsonlRecord,
augment_claude_args,
count_hook_events,
display_cost_approval_popup,
ensure_claude_workspace_trusted,
inject_interrupt,
inject_user_message,
kill_session,
post_tools_changed,
prepare_bridge_dir,
read_assistant_text_since,
read_claude_session_id,
read_hook_events_from_offset,
read_launch_model,
read_message_deltas_from_offset,
read_permission_hook_config,
read_seen_claude_session_ids,
read_transcript_items_from_offset,
read_transcript_items_since,
read_transcript_path,
record_hook_event,
start_tool_relay,
stop_hook_seen_since,
write_tmux_target,
)
from omnigent.inner.datamodel import (
CredentialProxyEntry,
CredentialProxySpec,
CredentialSourceSpec,
OSEnvSandboxSpec,
)
from omnigent.reasoning_effort import CLAUDE_EFFORTS
@pytest.fixture(autouse=True)
def _trust_tmp_bridge_parent(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)
def _load_invocation_settings(args: list[str]) -> dict[str, Any]:
settings_path = Path(args[args.index("--settings") + 1])
return json.loads(settings_path.read_text(encoding="utf-8"))
@pytest.fixture
def subprocess_bridge_root() -> Iterator[Path]:
"""
Yield a bridge root accepted by subprocess bridge tests.
:yields: Temporary directory path under the production trusted
Claude bridge root, so a child
``python -m omnigent.claude_native_bridge`` accepts bridge
writes without inheriting pytest monkeypatches.
"""
production_root = (
Path(tempfile.gettempdir())
/ f"omnigent-{claude_native_bridge.stable_user_id()}"
/ "claude-native"
)
production_root.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(production_root.parent, 0o700)
os.chmod(production_root, 0o700)
with tempfile.TemporaryDirectory(prefix="bridge-test-", dir=production_root) as raw:
yield Path(raw)
class _NoPrefixReadFile:
"""
File wrapper that fails if a reader touches bytes before a cursor.
:param handle: Wrapped file object returned by :class:`Path.open`.
:param min_read_offset: Smallest byte offset that may be read.
"""
def __init__(self, handle: Any, min_read_offset: int) -> None:
"""
Initialize the tracking wrapper.
:param handle: Wrapped file object returned by :class:`Path.open`.
:param min_read_offset: Smallest byte offset that may be read.
:returns: None.
"""
self._handle = handle
self._min_read_offset = min_read_offset
def __enter__(self) -> _NoPrefixReadFile:
"""
Enter the wrapped file context.
:returns: This wrapper.
"""
self._handle.__enter__()
return self
def __exit__(self, *args: object) -> object:
"""
Exit the wrapped file context.
:param args: Context-manager exception details.
:returns: Wrapped ``__exit__`` return value.
"""
return self._handle.__exit__(*args)
def seek(self, *args: object) -> object:
"""
Delegate a seek operation to the wrapped file.
:param args: Seek arguments.
:returns: Wrapped ``seek`` return value.
"""
return self._handle.seek(*args)
def tell(self) -> int:
"""
Return the wrapped file's current offset.
:returns: Current byte offset.
"""
return int(self._handle.tell())
def read(self, *args: object) -> object:
"""
Read bytes after asserting the prefix is not touched.
:param args: Read arguments.
:returns: Wrapped ``read`` return value.
"""
self._assert_not_prefix_read()
return self._handle.read(*args)
def readline(self, *args: object) -> object:
"""
Read one line after asserting the prefix is not touched.
:param args: Readline arguments.
:returns: Wrapped ``readline`` return value.
"""
self._assert_not_prefix_read()
return self._handle.readline(*args)
def _assert_not_prefix_read(self) -> None:
"""
Raise if the wrapped file is positioned before the saved cursor.
:returns: None.
"""
if self.tell() < self._min_read_offset:
raise AssertionError("byte-offset reader touched the JSONL prefix")
def _fail_if_path_reads_before_offset(
monkeypatch: pytest.MonkeyPatch,
path: Path,
min_read_offset: int,
) -> None:
"""
Patch :class:`Path.open` so reads before ``min_read_offset`` fail.
:param monkeypatch: Pytest monkeypatch fixture.
:param path: Path whose reads should be tracked.
:param min_read_offset: Smallest byte offset that may be read.
:returns: None.
"""
real_open = Path.open
def _open_with_tracking(self: Path, *args: Any, **kwargs: Any) -> Any:
"""
Wrap reads for the tracked path and delegate all other opens.
:param self: Path being opened.
:param args: Positional ``Path.open`` arguments.
:param kwargs: Keyword ``Path.open`` arguments.
:returns: File object or tracking wrapper.
"""
handle = real_open(self, *args, **kwargs)
if self == path:
return _NoPrefixReadFile(handle, min_read_offset)
return handle
monkeypatch.setattr(Path, "open", _open_with_tracking)
def test_prepare_bridge_dir_preserves_token_and_updates_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Bridge setup is stable across wrapper re-runs for one session.
If this regresses, reattaching ``omnigent claude`` can rotate the
bearer token while the already-running MCP server still expects the
old token.
"""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
first = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
)
first_config = json.loads((first / "bridge.json").read_text(encoding="utf-8"))
second = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path / "later",
)
second_config = json.loads((second / "bridge.json").read_text(encoding="utf-8"))
assert first == second
assert second_config["token"] == first_config["token"]
assert second_config["workspace"] == str(tmp_path / "later")
assert "headers" not in second_config
def test_prepare_bridge_dir_preserves_permission_hook_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Re-preparing a bridge keeps the permission command hook's Omnigent URL.
The claude-native ``PermissionRequest`` command hook reads
``permission_hook.json`` at hook time to learn which Omnigent server to
POST to (the URL is not baked into Claude's launch args). A
rebind/reattach that re-runs ``prepare_bridge_dir`` must NOT wipe
that file — if it does, the permission subprocess bails with "AP
server URL missing" and approval prompts silently stop reaching the
web UI (a regression we guard against).
"""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
# ``augment_claude_args`` is the production path that writes
# permission_hook.json (via ``build_hook_settings``) on cold launch.
augment_claude_args(
(),
bridge_dir=bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
assert (bridge_dir / "permission_hook.json").exists()
# Simulate a wrapper rebind/reattach that re-prepares the bridge
# without relaunching the terminal (so build_hook_settings does not
# run again to recreate the file).
prepare_bridge_dir("conv_abc", workspace=tmp_path / "later")
config = read_permission_hook_config(bridge_dir)
assert config["ap_server_url"] == "http://127.0.0.1:8787"
assert config["ap_auth_headers"] == {"Authorization": "Bearer xyz"}
def test_prepare_bridge_dir_restricts_filesystem_permissions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Bridge directory and bearer token are not world-readable.
`/tmp` is shared with other Unix users on the host. The bridge dir
holds a bearer token the local MCP server uses; if its perms drift
to a default 0o755 (dir) / 0o644 (file) other users on the box can
read it and impersonate the runner against the MCP server. Per the
design doc §12 the dir must be 0o700 and bearer files 0o600. A
regression here would be invisible without an explicit stat assertion.
"""
import stat
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native")
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
bridge_json = bridge_dir / "bridge.json"
dir_mode = stat.S_IMODE(bridge_dir.stat().st_mode)
bearer_mode = stat.S_IMODE(bridge_json.stat().st_mode)
assert dir_mode == 0o700, (
f"bridge dir at {bridge_dir} has mode {oct(dir_mode)}; "
"expected 0o700 so other host users cannot enter it"
)
assert bearer_mode == 0o600, (
f"bearer file {bridge_json} has mode {oct(bearer_mode)}; "
"expected 0o600 so other host users cannot read the token"
)
def test_prepare_bridge_dir_refuses_symlinked_ancestor(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Pre-created symlinked bridge ancestor is refused, not silently followed.
Without `_ensure_secure_dir`, ``mkdir(parents=True, exist_ok=True)``
happily walks through a symlinked intermediate dir, redirecting the
bridge tree (including ``bridge.json``'s bearer token) to a path an
attacker controls. A regression that removes the validation — or
swaps it back to plain mkdir — would let this attack succeed.
"""
# Layout: tmp_path is the trusted parent. Place a "claude-native"
# symlink that points at a separate attacker-controlled directory
# before any prepare_bridge_dir() call runs.
attacker_dir = tmp_path / "attacker-controlled"
attacker_dir.mkdir()
symlink = tmp_path / "claude-native"
symlink.symlink_to(attacker_dir, target_is_directory=True)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", symlink)
with pytest.raises(RuntimeError, match="symlink"):
prepare_bridge_dir("conv_abc", workspace=tmp_path)
# Confirm the bearer token did NOT land in the attacker-controlled
# directory — the refusal happened before any file write.
assert not (attacker_dir / "bridge.json").exists()
def test_prepare_bridge_dir_persists_and_applies_resolved_sandbox(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A resolved sandbox reaches both the bridge config and its sys_os_* tools.
``enforce_sandbox``/``force_sandbox`` resolves a real sandbox onto the
session's ``os_env.sandbox`` upstream (``runner/app.py``'s
``_apply_sandbox_override_from_verdict``), but that decision used to
have no path into the claude-native bridge: ``prepare_bridge_dir``
never wrote it to the config file, and ``_build_tools`` unconditionally
hardcoded ``OSEnvSandboxSpec(type="none")`` regardless of the policy.
A server operator configuring ``force_sandbox`` for claude-native
sessions got silent unenforced host access. This locks in the fix:
the resolved sandbox must survive the config round-trip and actually
reach the ``OSEnvSpec`` that builds the ``sys_os_*`` tools.
``create_os_environment`` is patched at the boundary (same pattern as
``test_executor_factory_decodes_os_env_json`` in
``tests/inner/test_codex_harness.py``) rather than resolving a real
backend, so this is a platform-independent test of the config plumbing
— ``linux_bwrap`` and ``darwin_seatbelt`` only resolve on their own OS
and would otherwise fail this test on CI's Linux runners.
"""
resolved_sandbox = OSEnvSandboxSpec(type="linux_bwrap", allow_network=False)
bridge_dir = prepare_bridge_dir(
"conv_sandboxed",
workspace=tmp_path,
sandbox=resolved_sandbox,
)
config = json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))
assert config["sandbox"]["type"] == "linux_bwrap"
assert config["sandbox"]["allow_network"] is False
captured: dict[str, Any] = {}
def _fake_create_os_environment(spec: Any) -> None:
captured["spec"] = spec
return
monkeypatch.setattr(
"omnigent.inner.os_env.create_os_environment",
_fake_create_os_environment,
)
_build_tools(config)
sandbox = captured["spec"].sandbox
assert sandbox.type == "linux_bwrap"
assert sandbox.allow_network is False
def test_prepare_bridge_dir_without_sandbox_builds_unsandboxed_tools(
tmp_path: Path,
) -> None:
"""
No resolved sandbox preserves the prior unsandboxed default.
Sessions with nothing to carry (e.g. the ``omnigent claude`` CLI's own
synthesized wrapper spec, which intentionally declares
``os_env.sandbox.type: none``) must keep working exactly as before —
this is the fallback the fix above must not disturb.
"""
bridge_dir = prepare_bridge_dir("conv_unsandboxed", workspace=tmp_path)
config = json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))
assert "sandbox" not in config
tools, close_tools = _build_tools(config)
try:
os_env = tools["sys_os_shell"]._os_env
assert os_env is not None
assert os_env.sandbox.backend_type == "none"
assert os_env.sandbox.active is False
finally:
close_tools()
def test_prepare_bridge_dir_drops_credential_proxy_instead_of_corrupting_it(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A sandbox with a real ``credential_proxy`` never corrupts the round-trip.
``dataclasses.asdict`` flattens the nested ``CredentialProxySpec`` to a
plain dict, and a naive ``OSEnvSandboxSpec(**payload)`` on read has no
way to tell that dict apart from a real one — it gets assigned straight
through, so any sandboxed code that later dereferences ``.entries`` /
``.databricks`` on it crashes with ``AttributeError: 'dict' object has
no attribute ...``. ``credential_proxy`` is resolved parent-side only
and was never meant to cross this boundary in the first place (see
``SandboxPolicy.to_jsonable``'s identical exclusion) — this locks in
that it is dropped, not corrupted: the bridge config never carries it,
and the rebuilt spec cleanly has it unset rather than a stray dict.
``create_os_environment`` is patched at the boundary (see
``test_prepare_bridge_dir_persists_and_applies_resolved_sandbox`` above)
so this is platform-independent — the sandbox type only needs to be
*something* non-``"none"``, not a backend real for the CI host's OS.
"""
sandbox = OSEnvSandboxSpec(
type="linux_bwrap",
credential_proxy=CredentialProxySpec(
entries=[
CredentialProxyEntry(
host="github.com",
scheme="bearer",
source=CredentialSourceSpec(kind="env", env="GH_TOKEN"),
)
],
),
)
bridge_dir = prepare_bridge_dir(
"conv_credproxy",
workspace=tmp_path,
sandbox=sandbox,
)
config = json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))
assert "credential_proxy" not in config["sandbox"]
assert config["sandbox"]["type"] == "linux_bwrap"
captured: dict[str, Any] = {}
def _fake_create_os_environment(spec: Any) -> None:
captured["spec"] = spec
return
monkeypatch.setattr(
"omnigent.inner.os_env.create_os_environment",
_fake_create_os_environment,
)
_build_tools(config)
sandbox_rebuilt = captured["spec"].sandbox
assert sandbox_rebuilt.type == "linux_bwrap"
assert sandbox_rebuilt.credential_proxy is None
def test_ensure_secure_dir_succeeds_without_getuid(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Windows lacks ``os.getuid()``; bridge dir creation must still work."""
monkeypatch.delattr(os, "getuid", raising=False)
bridge_dir = tmp_path / "bridge-without-getuid"
claude_native_bridge._ensure_secure_dir(bridge_dir)
claude_native_bridge._ensure_secure_dir(bridge_dir)
assert bridge_dir.is_dir()
def test_trusted_parent_accepts_qwen_native_bridge_dir(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The relay's bridge-root allowlist accepts qwen-native bridge dirs.
The comment relay (``start_tool_relay`` → ``_ensure_secure_dir`` →
``_trusted_parent_for_bridge_dir``) writes its JSON file under the
harness's bridge dir, validating it lives below a known bridge root.
qwen-native reuses this relay but keeps files under its own root
(``$TMPDIR/omnigent-<uid>/qwen-native``); if that root is missing from the
allowlist, every qwen-native session raises ``not under an allowed bridge
root`` and the relay never starts (observed in a live runner log). This
pins the qwen-native branch so the regression can't return.
"""
from omnigent import qwen_native_bridge
# Distinct claude root so the qwen target can't match the claude branch
# first (the autouse fixture points the claude root at ``tmp_path``).
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native")
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
# qwen root mirrors production shape: <uid-scoped temp>/qwen-native.
qwen_root = tmp_path / "omnigent-test" / "qwen-native"
monkeypatch.setattr(qwen_native_bridge, "_BRIDGE_ROOT", qwen_root)
target = claude_native_bridge._absolute_syntactic_path(qwen_root / "abc123")
trusted = claude_native_bridge._trusted_parent_for_bridge_dir(target)
# Same anchor as cursor-native: the uid-scoped temp dir's parent.
assert trusted == claude_native_bridge._absolute_syntactic_path(qwen_root.parent.parent)
def test_trusted_parent_accepts_kiro_native_bridge_dir(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The relay's bridge-root allowlist accepts kiro-native bridge dirs.
kiro-native reuses the shared ``serve-mcp`` / relay infrastructure but keeps
files under its own root (``$TMPDIR/omnigent-<uid>/kiro-native``). Without the
kiro branch, ``start_tool_relay`` -> ``_ensure_secure_dir`` ->
``_trusted_parent_for_bridge_dir`` raises ``not under an allowed bridge root``
and the relay (and serve-mcp's own ``server.json`` write) never start. This
pins the kiro-native branch.
"""
from omnigent import kiro_native_bridge
# Distinct claude root so the kiro target can't match the claude branch
# first (the autouse fixture points the claude root at ``tmp_path``).
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native")
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
# kiro root mirrors production shape: <uid-scoped temp>/kiro-native.
kiro_root = tmp_path / "omnigent-test" / "kiro-native"
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", kiro_root)
target = claude_native_bridge._absolute_syntactic_path(kiro_root / "abc123")
trusted = claude_native_bridge._trusted_parent_for_bridge_dir(target)
# Same anchor as cursor-native: the uid-scoped temp dir's parent.
assert trusted == claude_native_bridge._absolute_syntactic_path(kiro_root.parent.parent)
def test_trusted_parent_rejects_path_outside_all_roots_and_names_qwen(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A path under no known root is refused, and the error names the qwen root."""
from omnigent import qwen_native_bridge
# Distinct claude root so ``outside`` below isn't swept under it (the autouse
# fixture points the claude root at ``tmp_path``).
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native")
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
qwen_root = tmp_path / "omnigent-test" / "qwen-native"
monkeypatch.setattr(qwen_native_bridge, "_BRIDGE_ROOT", qwen_root)
outside = claude_native_bridge._absolute_syntactic_path(tmp_path / "somewhere-else" / "x")
with pytest.raises(RuntimeError, match="not under an allowed bridge root") as exc:
claude_native_bridge._trusted_parent_for_bridge_dir(outside)
assert "qwen-native" in str(exc.value)
def test_record_hook_event_updates_transcript_state(tmp_path: Path) -> None:
"""
Hook records expose Claude's JSONL transcript path to the executor.
This fails if the harness would send channel messages but then have
no transcript cursor to read Claude's response from.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
assert read_transcript_path(bridge_dir) == transcript_path
assert count_hook_events(bridge_dir) == 1
def test_record_hook_event_ignores_foreign_identity(tmp_path: Path) -> None:
"""
A side-channel event cannot re-aim the bridge at a foreign transcript.
Ordinary mid-session events (tool edges, stops) carry whatever
session_id/transcript_path their context reports; one from another
session — e.g. a background Task agent's edge — used to silently
repoint ``transcript_path``, starving the forwarder on a file that
never grows. The event is still recorded; only identity is ignored.
"""
bridge_dir = tmp_path / "bridge"
pinned_transcript = tmp_path / "pinned.jsonl"
foreign_transcript = tmp_path / "agent-foreign.jsonl"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "pinned-session",
"transcript_path": str(pinned_transcript),
},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "foreign-session",
"transcript_path": str(foreign_transcript),
},
)
assert read_transcript_path(bridge_dir) == pinned_transcript
assert read_claude_session_id(bridge_dir) == "pinned-session"
assert read_seen_claude_session_ids(bridge_dir) == {"pinned-session"}
# The event itself is still recorded — only its identity is ignored.
assert count_hook_events(bridge_dir) == 2
def test_record_hook_event_session_start_rotates_identity(tmp_path: Path) -> None:
"""
A SessionStart announcement legitimately moves the bridge's identity.
/clear, /fork and resume all become a new Claude session announced by
a SessionStart hook; the pin must follow the front door so rotation
keeps working, and the prior id stays in the seen set for the
fork-vs-resume classifier.
"""
bridge_dir = tmp_path / "bridge"
old_transcript = tmp_path / "old.jsonl"
new_transcript = tmp_path / "new.jsonl"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "old-session",
"transcript_path": str(old_transcript),
},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "new-session",
"transcript_path": str(new_transcript),
},
)
assert read_transcript_path(bridge_dir) == new_transcript
assert read_claude_session_id(bridge_dir) == "new-session"
assert read_seen_claude_session_ids(bridge_dir) == {"old-session", "new-session"}
def test_record_hook_event_first_event_bootstraps_identity(tmp_path: Path) -> None:
"""
With no pin yet, the first identity-bearing event is trusted.
A fresh bridge may see an ordinary event before SessionStart lands
(hook ordering is not guaranteed); refusing it would leave the
forwarder with no transcript at all.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
record_hook_event(
bridge_dir,
{
"hook_event_name": "UserPromptSubmit",
"session_id": "claude-session",
"transcript_path": str(transcript_path),
},
)
assert read_transcript_path(bridge_dir) == transcript_path
assert read_claude_session_id(bridge_dir) == "claude-session"
def test_read_assistant_text_since_parses_claude_jsonl(tmp_path: Path) -> None:
"""
Transcript parsing returns only assistant text after the cursor.
The parser must not echo channel/user records back into the
Omnigent assistant stream.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps({"message": {"role": "user", "content": "hi"}}),
json.dumps(
{
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "first"}],
}
}
),
json.dumps(
{
"message": {
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "..."},
{"type": "text", "text": "second"},
],
}
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, chunks = read_assistant_text_since(transcript_path, 1)
assert cursor == 3, "cursor should advance past all three complete transcript records"
assert chunks == ["first", "second"]
def test_read_transcript_items_since_parses_claude_visible_events(tmp_path: Path) -> None:
"""
Transcript parsing preserves user text, tools, results, and output.
This covers the real Claude JSONL shapes observed in native CLI
runs: user message strings, assistant ``tool_use`` blocks, user
``tool_result`` blocks, assistant text, and metadata records that
must not become chat items.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps({"type": "permission-mode", "mode": "default"}),
json.dumps(
{
"type": "user",
"uuid": "user-1",
"message": {"role": "user", "content": "please inspect TODO.md"},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-tool-1",
"message": {
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "redacted"},
{
"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",
"is_error": False,
}
],
},
"toolUseResult": {
"type": "text",
"file": {"filePath": "TODO.md", "content": "TODO contents"},
},
}
),
json.dumps(
{
"type": "attachment",
"uuid": "metadata-attachment",
"attachment": {"type": "task_reminder", "content": [], "itemCount": 0},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-text-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Done."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert cursor == 6, "cursor should include metadata records even when they emit no items"
assert [item.item_type for item in items] == [
"message",
"function_call",
"function_call_output",
"message",
]
assert items[0].data == {
"role": "user",
"content": [{"type": "input_text", "text": "please inspect TODO.md"}],
}
tool_call = items[1]
assert tool_call.data["name"] == "Read"
assert json.loads(tool_call.data["arguments"]) == {"file_path": "TODO.md"}
assert tool_call.data["call_id"] == "toolu_read_1"
assert items[2].response_id == tool_call.response_id
assert items[2].data == {"call_id": "toolu_read_1", "output": "TODO contents"}
assert items[3].response_id == tool_call.response_id
assert items[3].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Done."}],
}
assert current_response_id == tool_call.response_id
def test_read_transcript_items_since_strips_inline_image_data(tmp_path: Path) -> None:
"""
Reading an image file must not replay its base64 data as prompt text.
Claude's ``Read`` tool returns image files as a list of
``{"type": "image", "source": {"type": "base64", ...}}`` blocks.
Serialized verbatim, a single image costs hundreds of thousands of
tokens and overflows the context window on resume. The stored
``function_call_output`` must drop the base64 payload.
"""
huge_b64 = "iVBORw0KGgo" + "A" * 100_000
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "assistant-tool-1",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_read_img",
"name": "Read",
"input": {"file_path": "chart.png"},
}
],
},
}
),
json.dumps(
{
"type": "user",
"uuid": "tool-result-1",
"parentUuid": "assistant-tool-1",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_read_img",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": huge_b64,
},
}
],
"is_error": False,
}
],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
outputs = [item for item in items if item.item_type == "function_call_output"]
assert len(outputs) == 1
output = outputs[0].data["output"]
assert huge_b64 not in output, "base64 image data must not be replayed as text"
# The placeholder names the media type and tells the agent how to
# recover the image (re-run the tool call), so it is not silently lost.
assert "image/png image omitted from history" in output
assert "re-run the tool call" in output
assert len(output) < 300
def test_read_transcript_items_since_marks_task_notifications_meta(tmp_path: Path) -> None:
task_notification = (
"<task-notification>\n"
"<task-id>b1mhekpmy</task-id>\n"
'<summary>Monitor event: "PR 2086 E2E UI + npm test CI results"</summary>\n'
"<event>E2E UI Tests (shard 2/3)\tfail\t1m50s\thttps://example.test</event>\n"
"</task-notification>"
)
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "task-notification-1",
"message": {"role": "user", "content": task_notification},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert current_response_id is None
assert [item.item_type for item in items] == ["message"]
assert items[0].data == {
"role": "user",
"is_meta": True,
"content": [{"type": "input_text", "text": task_notification}],
}
def test_read_transcript_items_since_flags_compact_summary(tmp_path: Path) -> None:
"""
An ``isCompactSummary`` user record is flagged, not rendered as a bubble.
Claude writes an ``isCompactSummary: true`` user record carrying the
continuation summary right after it compacts. The bridge must surface it
as a single ``message`` item with ``is_compact_summary=True`` (so the
forwarder can persist a durable compaction boundary) and preserve the
summary text — never drop it or route it through slash/scaffolding
detection.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "compact-summary-1",
"isCompactSummary": True,
"message": {
"role": "user",
"content": "This session is being continued. Prior summary: …",
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert len(items) == 1
assert items[0].is_compact_summary is True
assert items[0].item_type == "message"
assert items[0].data["content"][0]["text"].startswith("This session is being continued")
@pytest.mark.parametrize(
"raw_text",
[
"Prompt is too long",
"prompt is too long: 210000 tokens > 200000 maximum",
"Prompt is too long\n",
],
)
def test_read_transcript_rewrites_prompt_too_long(tmp_path: Path, raw_text: str) -> None:
"""
When Claude Code writes "Prompt is too long" to the transcript, the
bridge rewrites it to actionable guidance so the web UI shows
something useful instead of the raw API error.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "assistant",
"uuid": "err-1",
"message": {"role": "assistant", "content": raw_text},
}
)
+ "\n",
encoding="utf-8",
)
_, _, items = read_transcript_items_since(transcript_path, 0, agent_name="claude-native-ui")
assert len(items) == 1
text = items[0].data["content"][0]["text"]
assert "Context limit reached" in text
assert "/compact" in text
assert "/clear" in text
def test_read_transcript_items_from_offset_skips_existing_prefix(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Byte-offset transcript reads do not reparse old JSONL prefixes.
A poll after a large existing transcript should seek directly to
the saved byte offset. The patched file wrapper raises if reads
touch the old prefix, so this test fails under the prior full-file
rescan behavior.
"""
transcript_path = tmp_path / "session.jsonl"
prefix = "".join(
json.dumps(
{
"type": "user",
"uuid": f"prefix-{index}",
"message": {"role": "user", "content": f"prefix {index}"},
}
)
+ "\n"
for index in range(100)
)
transcript_path.write_text(
prefix
+ json.dumps(
{
"type": "assistant",
"uuid": "assistant-new",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "new output"}],
},
}
)
+ "\n",
encoding="utf-8",
)
prefix_offset = len(prefix.encode("utf-8"))
_fail_if_path_reads_before_offset(monkeypatch, transcript_path, prefix_offset)
result = read_transcript_items_from_offset(
transcript_path,
prefix_offset,
start_line=100,
agent_name="claude-native-ui",
)
assert result.line_cursor == 101
assert result.byte_offset == transcript_path.stat().st_size
assert [item.item_type for item in result.items] == ["message"]
assert result.items[0].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "new output"}],
}
def test_read_transcript_items_from_offset_preserves_partial_trailing_line(
tmp_path: Path,
) -> None:
"""
A partial trailing transcript JSON record is retried after completion.
Claude writes JSONL append-only, and a poll can land after bytes
are written but before the final newline. The reader must not
advance the byte offset in that case or the completed record will
be skipped forever.
"""
transcript_path = tmp_path / "session.jsonl"
partial = (
'{"type":"assistant","uuid":"assistant-partial","message":{"role":"assistant",'
'"content":[{"type":"text","text":"eventually complete"}]}'
)
transcript_path.write_text(partial, encoding="utf-8")
first = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
)
assert first.line_cursor == 0
assert first.byte_offset == 0
assert first.items == []
with transcript_path.open("a", encoding="utf-8") as handle:
handle.write("}\n")
second = read_transcript_items_from_offset(
transcript_path,
first.byte_offset,
start_line=first.line_cursor,
agent_name="claude-native-ui",
)
assert second.line_cursor == 1
assert second.byte_offset == transcript_path.stat().st_size
assert [item.data for item in second.items] == [
{
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "eventually complete"}],
}
]
def _transcript_line(entry: dict[str, Any]) -> str:
"""
Serialize one transcript entry as a JSONL line.
:param entry: Decoded transcript record.
:returns: The record as a newline-terminated JSON string.
"""
return json.dumps(entry) + "\n"
def _assistant_text_entry(uuid: str, text: str) -> dict[str, Any]:
"""
Build a minimal assistant transcript entry.
:param uuid: Record uuid (drives deterministic ids).
:param text: Assistant output text.
:returns: The decoded transcript record.
"""
return {
"type": "assistant",
"uuid": uuid,
"message": {"role": "assistant", "content": [{"type": "text", "text": text}]},
}
def test_scheduled_wake_opens_marked_turn(tmp_path: Path) -> None:
"""
Assistant output inheriting a settled turn id opens a marked new turn.
Cron and wakeup firings re-invoke Claude with no user transcript
entry, so the resumed output would inherit the finished turn's id
forever. With ``settled_response_id`` set to that id, the parser
must mint a fresh id and prepend the scheduled-wake marker.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
_transcript_line(_assistant_text_entry("wake-1", "iteration two output"))
+ _transcript_line(_assistant_text_entry("wake-2", "more of iteration two")),
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
current_response_id="resp_prev_turn",
settled_response_id="resp_prev_turn",
)
kinds = [item.item_type for item in result.items]
assert kinds == ["message", "message", "message"]
marker = result.items[0]
assert marker.data["role"] == "user"
assert marker.data["content"] == [
{"type": "input_text", "text": claude_native_bridge._SCHEDULED_WAKE_MARKER_TEXT}
]
new_turn_id = marker.response_id
assert new_turn_id != "resp_prev_turn"
# The waking entry's output and the rest of the batch share the new id.
assert [item.response_id for item in result.items] == [new_turn_id] * 3
assert result.current_response_id == new_turn_id
# Re-reads are idempotent: same window, same ids (retries dedupe on them).
replay = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
current_response_id="resp_prev_turn",
settled_response_id="resp_prev_turn",
)
assert [item.source_id for item in replay.items] == [item.source_id for item in result.items]
assert [item.response_id for item in replay.items] == [
item.response_id for item in result.items
]
def test_scheduled_wake_requires_settled_turn(tmp_path: Path) -> None:
"""
Without a settle, post-turn assistant output inherits the active id.
Mid-turn output (including a delta-held tail flushed late) must keep
extending its own turn — no marker, no fresh id.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
_transcript_line(_assistant_text_entry("tail-1", "same turn tail")),
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
current_response_id="resp_live_turn",
)
assert [item.item_type for item in result.items] == ["message"]
assert result.items[0].data["role"] == "assistant"
assert result.items[0].response_id == "resp_live_turn"
assert result.current_response_id == "resp_live_turn"
def test_scheduled_wake_not_marked_for_post_compaction_output(tmp_path: Path) -> None:
"""
Post-compaction output in the SAME batch as the summary is no wake.
The compaction card is the boundary; the resumed output continues
the settled turn. A batch holding the ``isCompactSummary`` record
AND the resumed assistant entry must parse the resume with the
settle disarmed — no marker, same turn id.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
_transcript_line(
{
"type": "user",
"uuid": "compact-1",
"isCompactSummary": True,
"message": {"role": "user", "content": "Summary of the prior context."},
}
)
+ _transcript_line(_assistant_text_entry("post-compact", "continuing the task")),
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
current_response_id="resp_prev_turn",
settled_response_id="resp_prev_turn",
)
texts = [
block.get("text")
for item in result.items
if item.item_type == "message"
for block in item.data["content"]
if isinstance(block, dict)
]
assert claude_native_bridge._SCHEDULED_WAKE_MARKER_TEXT not in texts
resumed = result.items[-1]
assert resumed.data["role"] == "assistant"
assert resumed.response_id == "resp_prev_turn"
assert result.current_response_id == "resp_prev_turn"
def test_scheduled_wake_skips_tool_results_and_real_prompts(tmp_path: Path) -> None:
"""
Only assistant output triggers the wake; other entries behave as before.
A late tool result for the settled turn keeps that turn's id (its
call card pairs by id), and a REAL user prompt opens the next turn
the ordinary way — no wake marker anywhere.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
_transcript_line(
{
"type": "user",
"uuid": "late-result",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_bg_1",
"content": "background shell done",
}
],
},
}
)
+ _transcript_line(
{
"type": "user",
"uuid": "real-prompt",
"message": {"role": "user", "content": "next question"},
}
)
+ _transcript_line(_assistant_text_entry("next-turn", "answering the question")),
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
current_response_id="resp_prev_turn",
settled_response_id="resp_prev_turn",
)
texts = [
block.get("text")
for item in result.items
if item.item_type == "message"
for block in item.data["content"]
if isinstance(block, dict)
]
assert claude_native_bridge._SCHEDULED_WAKE_MARKER_TEXT not in texts
assert [item.item_type for item in result.items] == [
"function_call_output",
"message",
"message",
]
# The late result stays on the settled turn; the real prompt resets the
# id, so the answer opens a fresh turn without any wake involvement.
assert result.items[0].response_id == "resp_prev_turn"
assert result.items[2].data["role"] == "assistant"
assert result.items[2].response_id not in {"resp_prev_turn", None}
def test_read_transcript_line_cursor_migration_preserves_legacy_source_ids(
tmp_path: Path,
) -> None:
"""
Line-cursor migration keeps UUID-less fallback ids stable.
Older forwarder state can contain ``line-N`` source ids in
``seen_source_ids``. The compatibility reader must keep producing
that legacy id shape for the migration poll so already-posted
UUID-less items are not duplicated before future byte-offset polls
switch to byte-based fallbacks.
"""
transcript_path = tmp_path / "session.jsonl"
first_record = (
json.dumps(
{
"type": "user",
"message": {"role": "user", "content": "already forwarded"},
}
)
+ "\n"
)
transcript_path.write_text(
first_record
+ json.dumps(
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "new output"}],
},
}
)
+ "\n",
encoding="utf-8",
)
migrated = claude_native_bridge.read_transcript_items_since_with_position(
transcript_path,
1,
agent_name="claude-native-ui",
)
byte_offset = len(first_record.encode("utf-8"))
byte_reader = read_transcript_items_from_offset(
transcript_path,
byte_offset,
start_line=1,
agent_name="claude-native-ui",
)
assert [item.source_id for item in migrated.items] == ["line-2:0:message"]
assert [item.source_id for item in byte_reader.items] == [f"byte-{byte_offset}:0:message"]
def test_read_transcript_items_since_ignores_observed_status_records(tmp_path: Path) -> None:
"""
Non-conversation Claude JSONL records do not become Omnigent items.
The local transcript audit found status/UI records such as
``queue-operation``, ``branch-update``, ``progress``,
``pr-link``, and non-prompt attachments alongside normal
message records. They must not show up as chat items or reset
the active response id for following assistant output.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "assistant-tool-1",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_bash_1",
"name": "Bash",
"input": {"command": "sleep 1"},
}
],
},
}
),
json.dumps({"type": "queue-operation", "operation": "enqueue", "content": "STOP"}),
json.dumps({"type": "branch-update", "gitBranch": "feature/native-claude"}),
json.dumps(
{
"type": "progress",
"uuid": "progress-1",
"toolUseID": "toolu_bash_1",
"data": {"type": "hook_progress", "hookEvent": "PostToolUse"},
}
),
json.dumps(
{
"type": "pr-link",
"prNumber": 123,
"prRepository": "example/repo",
"prUrl": "https://example.invalid/pr/123",
}
),
json.dumps(
{
"type": "attachment",
"uuid": "file-attachment",
"attachment": {
"type": "file",
"filename": "TODO.md",
"displayPath": "TODO.md",
"content": [],
},
}
),
json.dumps(
{
"type": "user",
"uuid": "tool-result-1",
"parentUuid": "assistant-tool-1",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_bash_1",
"content": "(No output)",
}
],
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-text-1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Done."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert cursor == 8, "cursor should advance past ignored status/UI records"
assert [item.item_type for item in items] == [
"function_call",
"function_call_output",
"message",
]
assert items[0].data["call_id"] == "toolu_bash_1"
assert items[1].data == {"call_id": "toolu_bash_1", "output": "(No output)"}
assert items[2].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Done."}],
}
assert items[0].response_id == items[1].response_id == items[2].response_id
assert current_response_id == items[0].response_id
def test_read_transcript_items_since_parses_interruption_queued_prompt(tmp_path: Path) -> None:
"""
Queued prompt attachments become user messages in the transcript.
Claude records text entered while a tool/assistant turn is still
busy as ``attachment.type == "queued_command"``. This test uses
that real interruption shape so a regression would drop the
user's ``STOP`` message and incorrectly group the assistant's
post-interrupt response under the pre-interrupt response id.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "user-count",
"message": {
"role": "user",
"content": "count to 10 unless I tell you to stop",
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-three",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "3"}],
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-sleep",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_sleep_1",
"name": "Bash",
"input": {"command": "sleep 1"},
}
],
},
}
),
json.dumps(
{
"type": "queue-operation",
"operation": "enqueue",
"content": "STOP",
}
),
json.dumps(
{
"type": "user",
"uuid": "sleep-result",
"parentUuid": "assistant-sleep",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_sleep_1",
"content": "(Bash completed with no output)",
}
],
},
}
),
json.dumps(
{
"type": "attachment",
"uuid": "queued-stop",
"attachment": {
"type": "queued_command",
"prompt": "STOP",
"commandMode": "prompt",
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-stopped",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Stopped at 3."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert cursor == 7, "cursor should include the queued-command attachment record"
assert [item.item_type for item in items] == [
"message",
"message",
"function_call",
"function_call_output",
"message",
"message",
]
assert items[4].source_id == "queued-stop:0:message"
assert items[4].data == {
"role": "user",
"content": [{"type": "input_text", "text": "STOP"}],
}
assert items[5].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Stopped at 3."}],
}
assert items[3].response_id == items[2].response_id
assert items[4].response_id != items[3].response_id
assert items[5].response_id != items[3].response_id
assert current_response_id == items[5].response_id
def test_read_hook_events_from_offset_skips_existing_prefix(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Byte-offset hook reads parse only hook records appended after the cursor.
This guards the status forwarder hot path: many old hook records
must not be rescanned on every poll once a byte cursor exists.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
hooks_path = bridge_dir / "hooks.jsonl"
prefix = "".join(
json.dumps({"payload": {"hook_event_name": "SessionStart"}}) + "\n" for _index in range(50)
)
hooks_path.write_text(
prefix + json.dumps({"payload": {"hook_event_name": "Stop"}}) + "\n",
encoding="utf-8",
)
prefix_offset = len(prefix.encode("utf-8"))
_fail_if_path_reads_before_offset(monkeypatch, hooks_path, prefix_offset)
result = read_hook_events_from_offset(
bridge_dir,
prefix_offset,
start_event_count=50,
)
assert result.event_cursor == 51
assert result.byte_offset == hooks_path.stat().st_size
assert [record.event_name for record in result.records] == ["Stop"]
def test_read_hook_events_from_offset_preserves_partial_trailing_line(tmp_path: Path) -> None:
"""
A partial trailing hook JSON record is not skipped.
Hook processes append status edges concurrently with the forwarder
poll loop. If the reader advanced past a partial line, the web UI
could miss the matching ``running`` or ``idle`` transition.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
hooks_path = bridge_dir / "hooks.jsonl"
hooks_path.write_text('{"payload":{"hook_event_name":"Stop"', encoding="utf-8")
first = read_hook_events_from_offset(bridge_dir, 0, start_event_count=0)
assert first.event_cursor == 0
assert first.byte_offset == 0
assert first.records == []
with hooks_path.open("a", encoding="utf-8") as handle:
handle.write("}}\n")
second = read_hook_events_from_offset(
bridge_dir,
first.byte_offset,
start_event_count=first.event_cursor,
)
assert second.event_cursor == 1
assert second.byte_offset == hooks_path.stat().st_size
assert [record.event_name for record in second.records] == ["Stop"]
def test_read_transcript_items_since_surfaces_skill_as_slash_command(
tmp_path: Path,
) -> None:
"""
A Skill marker record becomes one ``slash_command`` item, not
a user bubble (the raw-markup regression) and not a drop
(the over-correction). The subsequent assistant turn
still surfaces.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "slash-skill",
"message": {
"role": "user",
"content": (
"<command-name>/dev-productivity:simplify</command-name>\n"
" <command-message>simplify</command-message>\n"
" <command-args></command-args>"
),
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-after",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Done."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert cursor == 2
assert [item.item_type for item in items] == ["slash_command", "message"]
# Leading ``/`` stripped, args empty, ``output`` omitted via exclude_none.
# ``kind`` is ``"skill"`` because the name isn't in the surfaced-CLI
# set (``effort``, ``clear``, ``compact``, ``model``, ``ultrareview``).
assert items[0].data == {
"agent": "claude-native-ui",
"kind": "skill",
"name": "dev-productivity:simplify",
"arguments": "",
}
assert items[1].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Done."}],
}
# Slash command + the assistant turn it triggered must cluster in
# one bubble — see test_slash_command_does_not_inherit_prior_turn_response_id.
assert items[0].response_id == items[1].response_id
def test_slash_command_does_not_inherit_prior_turn_response_id(tmp_path: Path) -> None:
"""
Slash command opens a new logical turn. With a prior assistant
record establishing ``current_response_id``, the slash command +
the assistant text it triggers must share their own
``response_id`` (distinct from the prior turn) so the web UI
clusters them in one bubble instead of folding the follow-up
text back into the older bubble.
"""
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-skill",
"message": {
"role": "user",
"content": (
"<command-name>/dev-productivity:simplify</command-name>\n"
" <command-message>simplify</command-message>\n"
" <command-args></command-args>"
),
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-after-skill",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Simplified."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert [item.item_type for item in items] == ["message", "slash_command", "message"]
prior_id, slash_id, after_id = (item.response_id for item in items)
assert prior_id != slash_id
assert slash_id == after_id
def test_read_transcript_items_since_drops_cli_builtin_slash_commands(
tmp_path: Path,
) -> None:
"""
A real ``/login`` invocation generates three sibling
``role=user`` JSONL records: ``isMeta=true`` caveat, the
``<command-name>`` echo, and a follow-up
``<local-command-stdout>`` carrying the CLI's reply. All three
must drop. Legitimate user messages around them still surface.
Uses ``/login`` because it's in the DROPPED set; commands in the
SURFACED set (``/effort``, ``/clear``, ``/compact``, ``/model``,
``/ultrareview``) deliberately emit a ``slash_command`` item now,
so they'd fail this assertion — see the surfaces-cli-commands
parametric test below.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "real-user-msg",
"message": {"role": "user", "content": "hi"},
}
),
json.dumps(
{
"type": "user",
"uuid": "caveat-meta",
"message": {
"role": "user",
"content": (
"<local-command-caveat>Caveat: ...</local-command-caveat>"
),
},
"isMeta": True,
}
),
json.dumps(
{
"type": "user",
"uuid": "slash-login",
"message": {
"role": "user",
"content": (
"<command-name>/login</command-name>\n"
" <command-message>login</command-message>\n"
" <command-args></command-args>"
),
},
}
),
json.dumps(
{
"type": "user",
"uuid": "login-stdout",
"message": {
"role": "user",
"content": (
"<local-command-stdout>Opening browser…</local-command-stdout>"
),
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-after",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Done."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
# Five lines consumed; only the real user msg + final assistant text
# become items. The stdout record on its own line is the leak that
# rendered as a user bubble before this fix.
assert cursor == 5
assert [item.item_type for item in items] == ["message", "message"]
assert items[0].data == {
"role": "user",
"content": [{"type": "input_text", "text": "hi"}],
}
assert items[1].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Done."}],
}
@pytest.mark.parametrize(
"marker_content",
[
"<local-command-stdout>Set effort level to max</local-command-stdout>",
"<local-command-stderr>command failed</local-command-stderr>",
# Standalone command-message / command-args records (4 of these
# appear in the real transcripts without a preceding command-name).
"<command-message>effort</command-message>",
"<command-args>max</command-args>",
],
)
def test_read_transcript_items_since_drops_standalone_cli_scaffolding_records(
tmp_path: Path, marker_content: str
) -> None:
"""
Any ``role=user`` record whose content starts with a known CLI
scaffolding marker is CLI bookkeeping, not user content. Each
must drop instead of rendering markup as a user bubble.
Note: ``<bash-input>``, ``<bash-stdout>``, and ``<bash-stderr>`` are
intentionally excluded here — they are surfaced as ``terminal_command``
items (see ``test_read_transcript_items_since_surfaces_terminal_command_*``).
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "scaffolding",
"message": {"role": "user", "content": marker_content},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert items == [], (
f"CLI scaffolding ({marker_content[:40]}...) must drop; "
f"bridge emitted {[item.item_type for item in items]}"
)
@pytest.mark.parametrize(
"content",
[
# String form (how Claude Code ships it today), both variants.
"[Request interrupted by user]",
"[Request interrupted by user for tool use]",
# List form — defensive: Claude's JSONL shape isn't under our control.
[{"type": "text", "text": "[Request interrupted by user]"}],
],
)
def test_read_transcript_items_since_keeps_interrupt_marker(
tmp_path: Path, content: object
) -> None:
"""
Claude's ``[Request interrupted by user]`` record is mirrored, not dropped.
We deliberately keep it in history (it mirrors Claude's own session); the
web UI re-classifies it as a muted "System: Interrupted" marker via
``parseSystemMessage`` rather than a raw user bubble. Guards against
re-adding a bridge-side drop filter, which would starve the UI of the
marker and leave a reload with no sign the turn was interrupted.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "interrupt",
"message": {"role": "user", "content": content},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert [item.item_type for item in items] == ["message"], (
f"Interrupt marker must be mirrored as a message item for the UI to "
f"re-classify; bridge emitted {[item.item_type for item in items]}"
)
def test_read_transcript_items_since_surfaces_bash_input_as_terminal_command(
tmp_path: Path,
) -> None:
"""
A ``role=user`` record starting with ``<bash-input>`` is emitted by
Claude Code when the user types ``!cmd``. It must surface as a
``terminal_command`` item with ``kind="input"`` instead of being
dropped or rendered as raw markup.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "bash-in",
"message": {"role": "user", "content": "<bash-input>pwd</bash-input>"},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _response_id, items = read_transcript_items_since(
transcript_path, 0, agent_name="claude-native-ui"
)
assert len(items) == 1
assert items[0].item_type == "terminal_command"
assert items[0].data == {"kind": "input", "input": "pwd"}
assert isinstance(items[0].response_id, str)
assert items[0].response_id.startswith("resp_claude_")
def test_read_transcript_items_since_surfaces_bash_output_as_terminal_command(
tmp_path: Path,
) -> None:
"""
The sibling ``role=user`` record carrying ``<bash-stdout>`` and
``<bash-stderr>`` must surface as a ``terminal_command`` item with
``kind="output"`` so stdout/stderr are visible in the server UI.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "bash-out",
"message": {
"role": "user",
"content": "<bash-stdout>/home/user</bash-stdout><bash-stderr></bash-stderr>",
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _response_id, items = read_transcript_items_since(
transcript_path, 0, agent_name="claude-native-ui"
)
assert len(items) == 1
assert items[0].item_type == "terminal_command"
assert items[0].data == {"kind": "output", "stdout": "/home/user", "stderr": ""}
assert isinstance(items[0].response_id, str)
assert items[0].response_id.startswith("resp_claude_")
def test_read_transcript_items_since_surfaces_top_level_local_command(
tmp_path: Path,
) -> None:
"""
Top-level ``local_command`` shell records become terminal items.
Claude Code can record ``!cmd`` as ``subtype="local_command"``
with a top-level ``content`` field instead of a ``role=user``
message. This test fails if the parser only looks under
``message`` and silently drops shell commands before the
forwarder can POST them to AP.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"subtype": "local_command",
"uuid": "bash-in-local",
"content": "<bash-input>pwd</bash-input>",
}
),
json.dumps(
{
"type": "user",
"subtype": "local_command",
"uuid": "bash-out-local",
"content": (
"<bash-stdout>/home/user</bash-stdout><bash-stderr></bash-stderr>"
),
}
),
]
)
+ "\n",
encoding="utf-8",
)
_cursor, _response_id, items = read_transcript_items_since(
transcript_path, 0, agent_name="claude-native-ui"
)
assert [item.item_type for item in items] == ["terminal_command", "terminal_command"]
assert [item.data for item in items] == [
{"kind": "input", "input": "pwd"},
{"kind": "output", "stdout": "/home/user", "stderr": ""},
]
assert items[0].response_id == items[1].response_id
assert items[0].response_id.startswith("resp_claude_")
def test_read_transcript_items_since_surfaces_combined_shell_record(
tmp_path: Path,
) -> None:
"""
One transcript record can carry both shell input and output.
A regression that returns after the input tag would persist only
the invocation and lose the result, recreating the broken web
transcript that shows a local shell command with no output.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"subtype": "local_command",
"uuid": "bash-combined",
"content": ("<bash-input>printf hi</bash-input><bash-stdout>hi</bash-stdout>"),
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _response_id, items = read_transcript_items_since(
transcript_path, 0, agent_name="claude-native-ui"
)
assert [item.item_type for item in items] == ["terminal_command", "terminal_command"]
assert [item.data for item in items] == [
{"kind": "input", "input": "printf hi"},
{"kind": "output", "stdout": "hi", "stderr": None},
]
assert items[0].response_id == items[1].response_id
def test_read_transcript_items_since_surfaces_skill_when_command_name_is_not_first_tag(
tmp_path: Path,
) -> None:
"""
Skill invocations with args ship the tag order
``<command-message>…<command-name>…<command-args>…`` (real shape
observed in transcripts for e.g. ``/supervisor-agent-e2e-test use this skill``).
The dispatcher must detect ``<command-name>`` anywhere in the
content, not just at the start — otherwise the record falls into
the scaffolding-drop branch because ``<command-message>`` IS in
``_CLI_SCAFFOLDING_MARKERS`` and the skill silently disappears.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "slash-skill-with-args",
"message": {
"role": "user",
"content": (
"<command-message>supervisor-agent-e2e-test</command-message>\n"
"<command-name>/supervisor-agent-e2e-test</command-name>\n"
"<command-args>use this skill</command-args>"
),
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert len(items) == 1
assert items[0].item_type == "slash_command"
assert items[0].data == {
"agent": "claude-native-ui",
"kind": "skill",
"name": "supervisor-agent-e2e-test",
"arguments": "use this skill",
}
def test_read_transcript_items_since_carries_stdout_for_skill_with_output(
tmp_path: Path,
) -> None:
"""
Inline ``<local-command-stdout>`` is threaded into ``output``
so the renderer can show it in the expanded panel. Regex bugs
that grab the wrong group surface here.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "slash-skill-with-stdout",
"message": {
"role": "user",
"content": (
"<command-name>/oncall</command-name>\n"
"<command-message>oncall</command-message>\n"
"<command-args>file-bug</command-args>\n"
"<local-command-stdout>oncall: file-bug subcommand "
"started</local-command-stdout>"
),
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert len(items) == 1
assert items[0].item_type == "slash_command"
assert items[0].data == {
"agent": "claude-native-ui",
"kind": "skill",
"name": "oncall",
"arguments": "file-bug",
"output": "oncall: file-bug subcommand started",
}
@pytest.mark.parametrize(
"builtin_name",
[
"add-dir",
"plugin",
"terminal-setup",
],
)
def test_read_transcript_items_since_drops_recently_added_cli_builtins(
tmp_path: Path, builtin_name: str
) -> None:
"""
Names in the CLI built-in DROPPED set drop end-to-end — not
surface as fake Skill rows. Drives each through the full bridge
pipeline (set membership alone wouldn't catch a name that's in
the set but mishandled downstream).
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": f"slash-{builtin_name}",
"message": {
"role": "user",
"content": (
f"<command-name>/{builtin_name}</command-name>\n"
f" <command-message>{builtin_name}</command-message>\n"
" <command-args></command-args>"
),
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert items == [], (
f"/{builtin_name} must drop; bridge emitted {[item.item_type for item in items]}"
)
@pytest.mark.parametrize(
("command_name", "args"),
[
("effort", "high"),
("clear", ""),
("compact", ""),
("model", "claude-opus-4-7"),
("ultrareview", ""),
],
)
def test_read_transcript_items_since_surfaces_cli_commands_with_kind_command(
tmp_path: Path, command_name: str, args: str
) -> None:
"""
Surfaced CLI built-ins (``/effort``, ``/clear``, ``/compact``,
``/model``, ``/ultrareview``) emit a ``slash_command`` item with
``kind="command"`` — the renderer uses that to switch the prefix
label from "Skill" to "Command".
These commands change conversation-visible state (effort level,
context reset, compaction, model swap, kicking off a review). A
web observer must see them; otherwise the next assistant turn
appears to shift unprompted. If a regression promotes any of
these names back into the dropped set, items would be empty and
this test fails.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": f"slash-{command_name}",
"message": {
"role": "user",
"content": (
f"<command-name>/{command_name}</command-name>\n"
f" <command-message>{command_name}</command-message>\n"
f" <command-args>{args}</command-args>"
),
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert len(items) == 1
assert items[0].item_type == "slash_command"
assert items[0].data == {
"agent": "claude-native-ui",
"kind": "command",
"name": command_name,
"arguments": args,
}
def test_read_transcript_items_since_drops_malformed_slash_command_record(
tmp_path: Path,
) -> None:
"""
A truncated slash-command record (open tag, no close) drops
instead of falling through to the user-bubble path that would
render the markup verbatim. Subsequent legitimate
records still surface — one bad line must not kill the poll loop.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "slash-truncated",
"message": {
"role": "user",
# Start tag with no closing tag — pathological shape.
"content": "<command-name>/oncall",
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "assistant-after",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Done."}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
assert [item.item_type for item in items] == ["message"]
assert items[0].data == {
"role": "assistant",
"agent": "claude-native-ui",
"content": [{"type": "output_text", "text": "Done."}],
}
def test_read_transcript_items_since_drops_list_form_slash_command_text_block(
tmp_path: Path,
) -> None:
"""
Slash-command markup inside a list-form ``content`` text block
drops instead of leaking as a user bubble. Sibling tool_result
blocks in the same record still surface.
Today Claude Code emits slash-command records as string content
(covered by the surfaces/drops/malformed tests above). If that
serialization ever changes to list form, the string-branch guard
no longer fires — without the list-branch filter this regresses
to the raw-markup-as-user-bubble bug.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "list-form-slash",
"message": {
"role": "user",
"content": [
{
"type": "text",
"text": (
"<command-name>/dev-productivity:simplify"
"</command-name>\n"
" <command-args></command-args>"
),
},
{
"type": "tool_result",
"tool_use_id": "call_legit",
"content": "tool finished",
},
],
},
}
)
+ "\n",
encoding="utf-8",
)
_cursor, _current_response_id, items = read_transcript_items_since(
transcript_path,
0,
agent_name="claude-native-ui",
)
# The text block holding ``<command-name>…`` is dropped; only the
# legitimate tool_result survives. If the list-branch filter
# regresses, items would contain a "message" with the raw markup
# as ``input_text``.
assert [item.item_type for item in items] == ["function_call_output"]
assert items[0].data["call_id"] == "call_legit"
def test_read_transcript_items_from_offset_surfaces_skill_as_slash_command(
tmp_path: Path,
) -> None:
"""
Byte-offset transcript reads emit ``slash_command`` items, not
raw user bubbles, for Claude Code Skill records.
Both transcript readers (line-cursor ``read_transcript_items_since``
and byte-offset ``read_transcript_items_from_offset``) flow through
the same ``_user_transcript_items_from_entry`` parser. The line-
cursor path is covered above; this pins the byte-offset path so a
future change to the byte-offset wrapper that drops ``agent_name``
or ``record_offset`` from the parser call fails loud here.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "user",
"uuid": "byte-offset-skill",
"message": {
"role": "user",
"content": (
"<command-name>/dev-productivity:simplify</command-name>\n"
" <command-args></command-args>"
),
},
}
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path,
0,
start_line=0,
agent_name="claude-native-ui",
)
assert result.byte_offset == transcript_path.stat().st_size
assert [item.item_type for item in result.items] == ["slash_command"]
assert result.items[0].data == {
"agent": "claude-native-ui",
"kind": "skill",
"name": "dev-productivity:simplify",
"arguments": "",
}
def test_augment_claude_args_injects_mcp_and_hooks(tmp_path: Path) -> None:
"""
Claude receives the Omnigent MCP server and hook settings in one launch.
This fails if the terminal starts Claude without the bridge pieces
needed for tool dispatch / transcript discovery. Also asserts the
org-blocked Channels flag is not present.
"""
args = augment_claude_args(
("--resume", "abc"),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
assert args[:2] == ["--resume", "abc"]
mcp_config = json.loads(args[args.index("--mcp-config") + 1])
server = mcp_config["mcpServers"]["omnigent"]
assert server["command"] == "/venv/bin/python"
assert server["args"][-2:] == ["--bridge-dir", str(tmp_path)]
# Web-UI input does not flow through Claude Channels — that
# capability is blocked at the org level. The wrapper must not
# pass the development-channels flag.
assert "--dangerously-load-development-channels" not in args
settings = _load_invocation_settings(args)
assert "omnigent.claude_native_hook" in settings["hooks"]["Stop"][0]["hooks"][0]["command"]
# ``PreCompact`` must be wired so the forwarder can surface
# ``response.compaction.in_progress`` while Claude compacts in the
# terminal. Missing it = no "Compacting…" spinner for claude-native.
assert "PreCompact" in settings["hooks"], (
f"PreCompact hook must be registered; got hooks {sorted(settings['hooks'])!r}."
)
assert (
"omnigent.claude_native_hook" in settings["hooks"]["PreCompact"][0]["hooks"][0]["command"]
)
# No built-in tools are disabled anymore: ``AskUserQuestion``
# routes through its dedicated PreToolUse hook (answers injected
# via ``updatedInput.answers``) and ``ExitPlanMode`` surfaces
# through the standard PermissionRequest elicitation card, so the
# wrapper must not inject a ``--disallowedTools`` flag of its own.
assert "--disallowedTools" not in args
@pytest.mark.parametrize(
"api_key_helper",
(
"printf %s sk-sentinel-do-not-use",
"credential-helper --token sk-sentinel-do-not-use",
),
)
def test_augment_claude_args_materializes_api_key_helper(
tmp_path: Path,
api_key_helper: str,
) -> None:
bridge_dir = tmp_path / "session"
args = augment_claude_args(
(),
bridge_dir=bridge_dir,
api_key_helper=api_key_helper,
)
settings_path = Path(args[args.index("--settings") + 1])
settings = _load_invocation_settings(args)
assert all("sk-sentinel-do-not-use" not in arg for arg in args)
assert settings["apiKeyHelper"] == api_key_helper
assert settings_path.parent == bridge_dir
assert bridge_dir.stat().st_mode & 0o777 == 0o700
assert settings_path.stat().st_mode & 0o777 == 0o600
def test_augment_claude_args_mirrors_launch_overrides_into_settings(
tmp_path: Path,
) -> None:
"""
Launch-time overrides are duplicated into the ``--settings`` sidecar.
Claude Code has had restart/re-exec paths that preserve ``--settings``
while rebuilding argv without ``--model`` / ``--effort`` /
``--permission-mode``. Mirroring the same effective values into the
sidecar prevents a restarted wrapped CLI from falling back to the user's
global ``~/.claude/settings.json`` and diverging from the Omnigent
session pill.
"""
args = augment_claude_args(
("--model", "claude-fable-5", "--permission-mode", "auto", "--effort", "xhigh"),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
settings = _load_invocation_settings(args)
assert settings["model"] == "claude-fable-5"
assert settings["permissions"] == {"defaultMode": "auto"}
assert settings["effortLevel"] == "xhigh"
def test_augment_claude_args_mirrors_joined_model_arg_into_settings(
tmp_path: Path,
) -> None:
"""The ``--model=<id>`` spelling is mirrored just like ``--model <id>``."""
args = augment_claude_args(
("--model=claude-sonnet-5",),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
settings = _load_invocation_settings(args)
assert settings["model"] == "claude-sonnet-5"
assert "permissions" not in settings
assert "effortLevel" not in settings
def test_augment_claude_args_uses_last_repeated_launch_override(
tmp_path: Path,
) -> None:
"""Repeated launch override flags mirror the same last-value-wins semantics."""
args = augment_claude_args(
(
"--model",
"claude-old",
"--model=claude-new",
"--permission-mode",
"default",
"--permission-mode",
"auto",
"--effort=low",
"--effort",
"high",
),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
settings = _load_invocation_settings(args)
assert settings["model"] == "claude-new"
assert settings["permissions"] == {"defaultMode": "auto"}
assert settings["effortLevel"] == "high"
def test_augment_claude_args_appends_caller_system_prompt(
tmp_path: Path,
) -> None:
"""Claude native appends framework instructions supplied by its launcher."""
framework_instruction = "Keep framework metadata separate."
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
append_system_prompt=framework_instruction,
)
assert args.count("--append-system-prompt") == 1
index = args.index("--append-system-prompt")
assert args[index + 1] == framework_instruction
def test_augment_claude_args_merges_caller_allowed_tools(tmp_path: Path) -> None:
"""Framework preapproval extends rather than replaces the user's allowlist."""
args = augment_claude_args(
("--allowedTools", "Bash,mcp__user__tool"),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
allowed_tools=("mcp__omnigent__sys_session_rename", "Bash"),
)
assert args.count("--allowedTools") == 1
index = args.index("--allowedTools")
assert args[index + 1].split(",") == [
"Bash",
"mcp__user__tool",
"mcp__omnigent__sys_session_rename",
]
def test_augment_claude_args_omits_unsupplied_system_prompt(tmp_path: Path) -> None:
"""The bridge does not invent framework policy on its own."""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
assert "--append-system-prompt" not in args
def test_augment_claude_args_merges_user_disallowed_tools(tmp_path: Path) -> None:
"""
A user-supplied ``--disallowedTools`` passes through unchanged.
With no wrapper-disabled tools, the user's flag must survive
verbatim — exactly one flag, exactly their value. This fails if the
wrapper appends a duplicate flag or mutates the user's disables.
"""
args = augment_claude_args(
("--disallowedTools", "Bash,Edit"),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
flag_indices = [i for i, arg in enumerate(args) if arg == "--disallowedTools"]
assert len(flag_indices) == 1
disallowed = args[flag_indices[0] + 1].split(",")
assert disallowed == ["Bash", "Edit"]
def test_augment_claude_args_injects_plugin_dir_for_bundle_with_skills(
tmp_path: Path,
) -> None:
"""
A bundle that ships ``skills/`` is exposed to Claude Code via
``--plugin-dir <bundle>``, with a plugin manifest written so the
skills label as ``<agent>:<skill>``.
This is the claude-native parity for the SDK executor's plugin
wiring. It fails if a deployed agent's bundled skills never reach the
real ``claude`` CLI (the gap before this change — native ignores the
harness ``tools``/skill plumbing, so ``--plugin-dir`` is the only
surface). ``skills_filter`` defaults to ``"all"``, so host skills use
the CLI's default sources and no ``--setting-sources`` is emitted.
"""
bundle = tmp_path / "bundle"
(bundle / "skills" / "authoring").mkdir(parents=True)
(bundle / "skills" / "authoring" / "SKILL.md").write_text("# authoring\n")
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
bundle_dir=bundle,
agent_name="researcher",
)
assert "--plugin-dir" in args
# The plugin path is the bundle root (Claude discovers
# <bundle>/skills/<dir>/SKILL.md under the plugin convention).
assert args[args.index("--plugin-dir") + 1] == str(bundle)
# "all" → host skills via the CLI default; no explicit override.
assert "--setting-sources" not in args
# The manifest gives the plugin a stable name for clean skill labels.
manifest = bundle / ".claude-plugin" / "plugin.json"
assert manifest.exists()
assert json.loads(manifest.read_text())["name"] == "researcher"
# The skill args are appended alongside the MCP/hook injection, not in
# place of it — both must reach the final launch command.
assert "--mcp-config" in args
def test_augment_claude_args_omits_permission_hook_without_omnigent_server(
tmp_path: Path,
) -> None:
"""
No ``PermissionRequest`` hook is registered when the wrapper has
no Omnigent server URL to point Claude at.
This guards the default path: a call site that forgets to plumb
the Omnigent server URL must NOT silently fall through to Claude's TUI
prompt for every tool — but it also must not register an HTTP hook
against an undefined URL. The expected behaviour is "no hook at
all", which means Claude uses its built-in permission flow.
"""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
settings = _load_invocation_settings(args)
assert "PermissionRequest" not in settings["hooks"]
def test_augment_claude_args_registers_permission_command_hook(
tmp_path: Path,
) -> None:
"""
Passing ``ap_server_url`` registers Claude's
``PermissionRequest`` hook as a command hook that resolves the
active Omnigent session at hook time.
If this regresses, Claude Code's built-in TUI permission prompt
appears every time the user is supposed to approve from the web
UI instead — silently breaking the blocking-UI-hook contract.
"""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
ap_server_url="http://127.0.0.1:8787/",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
settings = _load_invocation_settings(args)
permission = settings["hooks"]["PermissionRequest"]
assert len(permission) == 1
hooks = permission[0]["hooks"]
assert len(hooks) == 1
hook = hooks[0]
assert hook["type"] == "command"
# The command hook must carry a day-long timeout. Claude Code's
# default command-hook timeout (~60s) would otherwise kill the
# hook subprocess before the user answers the permission prompt in
# the web UI, flipping the card to "Resolved elsewhere" while the
# terminal prompt is still open. A failure here (missing key or a
# short value) means that premature auto-resolve has regressed.
assert hook["timeout"] == 86400
assert "omnigent.claude_native_hook permission-request" in hook["command"]
assert "--bridge-dir" in hook["command"]
assert "Bearer xyz" not in hook["command"]
permission_config = json.loads((tmp_path / "permission_hook.json").read_text(encoding="utf-8"))
assert permission_config["ap_server_url"] == "http://127.0.0.1:8787/"
assert permission_config["ap_auth_headers"] == {"Authorization": "Bearer xyz"}
session_start_command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "--conversation-url" not in session_start_command
assert "conv_abc" not in session_start_command
assert "companyAnnouncements" not in settings
# statusLine is now intentionally injected (it's the only place
# Claude Code surfaces ``context_window`` on stdin); ensure it
# captures to the raw file our forwarder normalizes.
assert "context_raw.json" in settings["statusLine"]["command"]
def test_augment_claude_args_registers_user_prompt_submit_policy_hook(
tmp_path: Path,
) -> None:
"""
Passing ``ap_server_url`` wires the evaluate-policy hook onto
``UserPromptSubmit`` alongside the transcript forwarder's status hook.
For native sessions the server-level ``_evaluate_input_policy`` skips
message events, so this hook is the sole REQUEST-phase gate (covering
both web-UI-injected and direct-terminal prompts). If it regressed,
native prompts would reach the model with no request-phase policy. The
forwarder's own UserPromptSubmit hook (status → running) must survive,
so the policy hook is appended, not substituted.
"""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
ap_server_url="http://127.0.0.1:8787/",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
settings = _load_invocation_settings(args)
entries = settings["hooks"]["UserPromptSubmit"]
commands = [h["command"] for entry in entries for h in entry["hooks"]]
# The forwarder status hook stays; the policy hook is appended.
assert any("evaluate-policy" in command for command in commands), (
f"UserPromptSubmit must carry the evaluate-policy hook; got {commands!r}."
)
assert any("evaluate-policy" not in command for command in commands), (
"The transcript forwarder's UserPromptSubmit hook must not be replaced."
)
def test_augment_claude_args_omits_user_prompt_submit_policy_hook_without_server(
tmp_path: Path,
) -> None:
"""
Without ``ap_server_url`` the UserPromptSubmit policy hook is not wired.
Policy hooks only make sense when an Omnigent server is configured to
evaluate against; the forwarder's status hook still registers, but no
evaluate-policy command should appear (mirrors PreToolUse/PostToolUse,
which are also gated behind ``ap_server_url``).
"""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
python_executable="/venv/bin/python",
)
settings = _load_invocation_settings(args)
entries = settings["hooks"]["UserPromptSubmit"]
commands = [h["command"] for entry in entries for h in entry["hooks"]]
assert all("evaluate-policy" not in command for command in commands)
def test_augment_claude_args_keeps_permission_hook_without_launch_session_id(
tmp_path: Path,
) -> None:
"""
Permission routing no longer depends on a launch-time conversation id.
The command hook reads the active session from bridge config at
hook time, so launch-time conversation ids are not part of the
hook settings contract.
"""
args = augment_claude_args(
(),
bridge_dir=tmp_path,
ap_server_url="http://127.0.0.1:8787",
)
settings = _load_invocation_settings(args)
assert settings["hooks"]["PermissionRequest"][0]["hooks"][0]["type"] == "command"
session_start_command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "--conversation-url" not in session_start_command
assert "companyAnnouncements" not in settings
assert "context_raw.json" in settings["statusLine"]["command"]
def test_mcp_server_initialize_omits_blocked_channel_capability(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
subprocess_bridge_root: Path,
) -> None:
"""
The MCP server does not advertise the org-blocked Channels capability.
This fails if a future change re-enables Claude Channels: Claude
Code would refuse to start with that capability advertised under
org policy, breaking the native wrapper.
"""
monkeypatch.setattr(
"omnigent.claude_native_bridge._TRUSTED_PARENT",
Path(tempfile.gettempdir()),
)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root)
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
assert proc.stdin is not None
assert proc.stdout is not None
proc.stdin.write(
json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {},
}
)
+ "\n"
)
proc.stdin.flush()
initialize = _read_json_line(proc.stdout, timeout_s=5.0)
assert initialize["id"] == 1
capabilities = initialize["result"]["capabilities"]
# Tools/list_changed is still needed for the active-turn relay.
assert capabilities["tools"] == {"listChanged": True}
# The experimental Claude/channel capability must NOT appear:
# advertising it triggers org-policy refusal in Claude Code.
assert "experimental" not in capabilities or "claude/channel" not in capabilities.get(
"experimental", {}
)
finally:
proc.terminate()
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5.0)
def test_write_tmux_target_persists_socket_and_target(tmp_path: Path) -> None:
"""
The runner advertisement file is shaped so the harness can find it.
This catches mismatches between runner-side writes and the
harness-side ``inject_user_message`` reader, which would cause
the web UI to time out with "tmux target not advertised yet".
"""
bridge_dir = tmp_path / "bridge"
before = time.time()
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
pid=12345,
)
after = time.time()
payload = json.loads((bridge_dir / "tmux.json").read_text(encoding="utf-8"))
assert payload["socket_path"] == "/tmp/example/tmux.sock"
assert payload["tmux_target"] == "claude:0.0"
assert payload["pid"] == 12345
assert before <= payload["updated_at"] <= after
@pytest.mark.parametrize(
"content,expected_payload",
[
# Plain single line: trailing CR appended (newline -> 0x0d).
("hello world", b"hello world\r"),
# Multi-line: interior newline rides as CR inside the paste so
# Claude's TUI keeps it as data instead of submitting per line.
("line one\nline two", b"line one\rline two\r"),
# Trailing "\" escapes the appended paste CR, not the submit Enter.
("deploy to prod\\", b"deploy to prod\\\r"),
# CRLF line endings coalesce to a single CR (no doubled blank line).
("a\r\nb", b"a\rb\r"),
# A stray ESC in the content is dropped so it can't prematurely
# close the bracketed-paste sequence on Claude's side.
("a\x1bb", b"ab\r"),
# Large payload (a PR diff in a sub-agent dispatch). Must ride the
# load-buffer file — per-byte send-keys argv tripped tmux's ~16KB
# client→server command cap with "command too long".
("x" * 100_000, b"x" * 100_000 + b"\r"),
],
ids=["plain", "multiline", "trailing-backslash", "crlf", "embedded-esc", "large"],
)
def test_inject_user_message_pastes_content_then_submits(
content: str,
expected_payload: bytes,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Every message is delivered as one bracketed paste, then a separate Enter.
The paste goes via ``load-buffer`` (from a temp file, so size is
unbounded — tmux caps a single command at ~16KB, which a PR-diff
dispatch exceeded as "command too long") then ``paste-buffer -p``,
with interior newlines encoded as CR so Claude Code's TUI keeps
multi-line input as a single editable block instead of submitting on
each newline (anthropics/claude-code#52126). Fails if the harness
regresses to send-keys argv delivery, drops the trailing Enter, or
stops clearing the stale buffer.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
captured: list[list[str]] = []
loaded_payloads: list[bytes] = []
# Simulates Claude Code's input-box state machine: empty before the
# paste, draft (collapsed-paste placeholder) after the paste, empty
# again once Enter submits. The paste-committed and submit-verified
# gates both poll capture-pane, so a static pane would either stall
# the paste gate (draft never appears) or fail verification.
tui = {"pane": " "}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Record tmux calls; simulate the TUI's input-box state.
``capture-pane`` calls (the readiness / paste-committed /
submit-verified polls) return the current simulated pane and
are not recorded — the assertions below count only the
delivery invocations. ``load-buffer`` calls read the temp
file's bytes at call time (the harness unlinks it after the
paste, so asserting later would race the cleanup).
``paste-buffer`` puts the draft into the simulated input box;
``Enter`` clears it (submit).
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (capture_output, text, etc.).
:returns: A fake CompletedProcess with rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "load-buffer" in cmd:
loaded_payloads.append(Path(cmd[-1]).read_bytes())
if "paste-buffer" in cmd:
tui["pane"] = " [Pasted text #1 +2 lines]"
if cmd[-1] == "Enter":
tui["pane"] = " "
captured.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content=content)
# C-a, C-k (clear), load-buffer, paste-buffer, Enter — fewer than 5
# means a delivery step was dropped.
assert len(captured) == 5, (
f"Expected 5 tmux calls (C-a, C-k, load-buffer, paste-buffer, Enter), got {len(captured)}."
)
clear_home, clear_kill, load, paste, submit = captured
assert clear_home[-1] == "C-a"
assert clear_kill[-1] == "C-k"
# The buffer file carried the normalized content + trailing CR. A
# missing trailing CR is the trailing-CR regression; a newline that stayed
# \n (not CR) is the anthropics/claude-code#52126 multi-line collapse.
assert loaded_payloads == [expected_payload]
assert load[:6] == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"load-buffer",
"-b",
"omnigent-paste",
]
# -p (bracketed-paste markers) dropped = newlines submit per-line in
# the TUI; -d dropped = stale buffer copies accumulate server-side.
assert paste == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"paste-buffer",
"-p",
"-d",
"-b",
"omnigent-paste",
"-t",
"claude:0.0",
]
assert submit == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"send-keys",
"-t",
"claude:0.0",
"Enter",
]
@pytest.mark.parametrize(
("content", "expected"),
[
("plain text", "plain text"),
("/help", "\ufeff/help"),
("/exit", "\ufeff/exit"),
("/quit now", "\ufeff/quit now"),
(" /help", " \ufeff/help"),
("/clear", "/clear"),
("/compact", "/compact"),
("/model sonnet-4", "/model sonnet-4"),
("/effort high", "/effort high"),
("/ultrareview", "/ultrareview"),
("/branch", "/branch"),
("/fork", "/fork"),
("/my-skill some args", "/my-skill some args"),
],
)
def test_escape_unsupported_slash_command(content: str, expected: str) -> None:
"""
Dropped Claude Code commands are escaped so they become plain text;
supported commands and skills are left untouched.
"""
assert _escape_unsupported_slash_command(content) == expected
def test_inject_user_message_escapes_unsupported_slash_command_payload(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Unsupported slash commands land in the tmux buffer with a leading
zero-width escape so Claude Code treats them as user text.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
loaded_payloads: list[bytes] = []
tui = {"pane": " "}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Record buffer payloads and simulate a ready TUI.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess with rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "load-buffer" in cmd:
loaded_payloads.append(Path(cmd[-1]).read_bytes())
if "paste-buffer" in cmd:
tui["pane"] = " [Pasted text #1 +2 lines]"
if cmd[-1] == "Enter":
tui["pane"] = " "
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content="/help")
assert loaded_payloads[0].startswith("\ufeff/help".encode("utf-8"))
inject_user_message(bridge_dir, content="/clear")
assert not loaded_payloads[1].startswith("\ufeff".encode("utf-8"))
def test_inject_user_message_raises_when_tmux_target_never_published(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The injection helper fails loud when the runner has not written tmux.json.
Failing silently would let the Omnigent turn complete with no user
message ever reaching Claude — the executor needs the
RuntimeError so it can surface an ExecutorError.
"""
with pytest.raises(RuntimeError, match="tmux target is not advertised"):
inject_user_message(tmp_path / "bridge", content="hi", timeout_s=0.0)
def test_inject_user_message_raises_on_tmux_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Non-zero tmux exit propagates as a RuntimeError with stderr context.
Without this, a broken tmux socket (server died, permission
issue) would be reported to the web UI as a successful turn
with no Claude response.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Pass the readiness gate, then fail the send-keys call.
``capture-pane`` returns a ready pane (rc=0) so the test
exercises the send-keys failure path specifically, not the
readiness timeout. All other tmux calls return rc=1 to
simulate a dead tmux server.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: A fake CompletedProcess (rc=0 for capture-pane,
rc=1 otherwise).
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=" ", stderr="")
return SimpleNamespace(
returncode=1,
stdout="",
stderr="no server running on /tmp/example/tmux.sock",
)
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="no server running"):
inject_user_message(bridge_dir, content="hi")
def test_inject_user_message_waits_for_claude_prompt_before_typing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Injection polls for Claude's prompt before sending any keystrokes.
On a freshly-created session ``tmux.json`` is advertised before
Claude Code's input box mounts. If we typed immediately the first
message would be dropped into the still-booting TUI. This pins that
no send-keys is issued until ``capture-pane`` shows the prompt
glyph, and that injection proceeds once it does.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
send_keys: list[list[str]] = []
# Prompt is absent for the first two polls (TUI still booting),
# then renders. A wrong gate would send keys during the empty
# polls; a correct gate waits for the third capture. After boot the
# fake behaves like the live input box: the paste deposits the
# draft, Enter clears it (so the submit-verification gate passes).
boot_panes = ["", "", " "]
capture_calls = {"n": 0}
tui = {"pane": " "}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Serve a not-ready-then-ready pane; record send-keys calls.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the next
boot pane until the prompt renders, then the simulated
input-box state; send-keys returns rc=0.
"""
del kwargs
if "capture-pane" in cmd:
idx = min(capture_calls["n"], len(boot_panes) - 1)
capture_calls["n"] += 1
# No keystrokes may have been sent before the prompt shows.
if boot_panes[idx] == "":
assert send_keys == [], "typed before Claude prompt rendered"
return SimpleNamespace(returncode=0, stdout="", stderr="")
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = " hello"
if cmd[-1] == "Enter":
tui["pane"] = " "
send_keys.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content="hello")
# Gate polled until the third capture (prompt present), then the
# five delivery calls (C-a, C-k, load-buffer, paste-buffer, Enter)
# fired.
assert capture_calls["n"] >= 3, (
f"Expected >=3 capture-pane polls before the prompt rendered, got {capture_calls['n']}."
)
assert len(send_keys) == 5, (
f"Expected 5 tmux calls (C-a, C-k, load-buffer, paste-buffer, Enter), "
f"got {len(send_keys)}."
)
clear_home, clear_kill, load, paste, submit = send_keys
assert clear_home[-1] == "C-a"
assert clear_kill[-1] == "C-k"
# The paste fires after the gate via the buffer path. The exact
# payload/flag assertions live in the dedicated paste test; here the
# gate ordering is the claim.
assert load[3] == "load-buffer"
assert paste[3] == "paste-buffer"
assert submit[-1] == "Enter"
def test_inject_user_message_raises_when_prompt_never_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Injection fails loud if Claude's prompt never renders (boot failed).
Without this the turn would complete with the message dropped and
the web UI stuck on "Working…". The RuntimeError surfaces as an
ExecutorError instead. No keystrokes must be sent on this path.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
send_keys: list[list[str]] = []
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Always report an empty (never-ready) pane.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns "".
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
send_keys.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="did not become ready"):
inject_user_message(bridge_dir, content="hi", timeout_s=0.3)
assert send_keys == [], "no keystrokes should be sent when the prompt never renders"
def test_inject_user_message_ignores_prompt_glyph_in_scrollback(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The readiness gate only trusts the prompt glyph in the pane tail.
Claude echoes prior user input (which may contain ````) into
scrollback. If the gate matched anywhere in the pane it would
falsely pass while the live input box is still booting. Here ````
appears only in an early line, with later non-empty lines lacking
it, so the gate must NOT treat the pane as ready.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
# `` only on an early line; the last several non-empty lines (the
# tail the gate scans) do not contain it.
scrollback = "\n".join(
[
" old prompt echo",
"output line 1",
"output line 2",
"output line 3",
"output line 4",
"output line 5",
]
)
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Return a pane with the glyph only in scrollback.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the
scrollback pane.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=scrollback, stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="did not become ready"):
inject_user_message(bridge_dir, content="hi", timeout_s=0.3)
def test_inject_user_message_resends_enter_when_first_submit_swallowed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A submit Enter swallowed by the TUI's paste burst is retried.
Claude Code coalesces rapid stdin bursts into a paste; an Enter
that lands inside that window becomes a newline in the draft
instead of a submit, and the message sits unsent — the "typed but
never sent" bug. The helper must observe (via capture-pane) that
the draft is still in the input box after Enter and re-send Enter
until it clears. Here the fake TUI swallows the first Enter
(draft stays put) and submits on the second; a regression to
fire-and-forget Enter would send exactly one and return "success"
with the message undelivered.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
# Shrink the polling cadence so the retry happens in milliseconds —
# the production defaults (1s retry spacing) would make this test slow.
monkeypatch.setattr("omnigent.claude_native_bridge._CLAUDE_READY_POLL_INTERVAL_S", 0.01)
monkeypatch.setattr("omnigent.claude_native_bridge._SUBMIT_RETRY_INTERVAL_S", 0.02)
monkeypatch.setattr("omnigent.claude_native_bridge._PASTE_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
enters: list[list[str]] = []
# Input-box state machine: the paste deposits the draft; the FIRST
# Enter is swallowed (folded into the paste burst — draft stays);
# the second Enter submits and clears the box.
tui = {"pane": " ", "swallowed_enters": 0}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Simulate a TUI that swallows the first submit Enter.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the
simulated input-box pane, other calls return rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = " fix the flaky test"
if cmd[-1] == "Enter":
enters.append(cmd)
if tui["swallowed_enters"] == 0:
tui["swallowed_enters"] = 1 # folded into the paste — draft stays
else:
tui["pane"] = " " # submitted — input box clears
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content="fix the flaky test")
# Exactly two Enters: the swallowed one plus one retry. One means
# the verify-retry loop regressed to fire-and-forget (the bug);
# three+ means retries keep firing after the draft cleared.
assert len(enters) == 2, (
f"Expected the swallowed Enter to be retried exactly once, got {len(enters)} Enter(s)."
)
def test_inject_user_message_raises_when_draft_never_submits(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Injection fails loud when the draft never leaves the input box.
If every submit Enter is swallowed (e.g. the pane is wedged in a
dialog), returning success would complete the Omnigent turn with
the message still sitting unsent in Claude's input box. The
RuntimeError surfaces as an ExecutorError instead.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._CLAUDE_READY_POLL_INTERVAL_S", 0.01)
monkeypatch.setattr("omnigent.claude_native_bridge._SUBMIT_RETRY_INTERVAL_S", 0.02)
monkeypatch.setattr("omnigent.claude_native_bridge._SUBMIT_VERIFY_TIMEOUT_S", 0.2)
monkeypatch.setattr("omnigent.claude_native_bridge._PASTE_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
tui = {"pane": " "}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Simulate a TUI where the draft never submits.
The paste deposits the draft and every Enter is ignored, so
the input box never clears.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the
simulated input-box pane, other calls return rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = " fix the flaky test"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="message was not delivered"):
inject_user_message(bridge_dir, content="fix the flaky test")
def test_inject_interrupt_sends_escape_keystroke(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
inject_interrupt issues ``tmux send-keys ... Escape`` on the pane.
Without the ``-l`` flag, tmux interprets ``Escape`` as the key
name (the single ASCII byte 0x1b). If the flag leaks in or the
keyword changes, Claude won't see a cancel and the Omnigent stop
button silently degrades back to a no-op.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
captured: list[list[str]] = []
class _FakeCompleted:
"""Fake CompletedProcess returned by the patched subprocess.run."""
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd: list[str], **kwargs: object) -> _FakeCompleted:
"""
Record the tmux command argv list.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: A fake CompletedProcess with rc=0.
"""
del kwargs
captured.append(cmd)
return _FakeCompleted()
monkeypatch.setattr("subprocess.run", _fake_run)
inject_interrupt(bridge_dir)
# One tmux call: send Escape (no literal flag). If 2+, a stray
# Enter or extra key was appended; if 0, the call was skipped.
assert len(captured) == 1, f"Expected 1 tmux send-keys call, got {len(captured)}."
assert captured[0] == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"send-keys",
"-t",
"claude:0.0",
"Escape",
]
def test_inject_interrupt_raises_when_tmux_target_never_published(
tmp_path: Path,
) -> None:
"""
inject_interrupt fails loud if tmux.json hasn't been written.
The runner route catches RuntimeError and returns 503 so the
Omnigent server falls back to the DBOS cancel path. Swallowing this
silently would make the stop button appear to work while
actually doing nothing.
"""
with pytest.raises(RuntimeError, match="tmux target is not advertised"):
inject_interrupt(tmp_path / "bridge", timeout_s=0.0)
def test_inject_interrupt_raises_on_tmux_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Non-zero tmux exit propagates as RuntimeError with stderr context.
Mirrors :func:`inject_user_message` so the runner route can
distinguish a bridge-not-ready error from a transport failure
via the message.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
class _FakeCompleted:
"""Fake CompletedProcess returning a non-zero exit code."""
returncode = 1
stdout = ""
stderr = "no server running on /tmp/example/tmux.sock"
def _fake_run(cmd: list[str], **kwargs: object) -> _FakeCompleted:
"""
Return a fake non-zero CompletedProcess.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: A fake CompletedProcess with rc=1.
"""
del cmd, kwargs
return _FakeCompleted()
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="no server running"):
inject_interrupt(bridge_dir)
def test_kill_session_issues_kill_session_on_target(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
kill_session issues exactly ``tmux kill-session -t <target>``.
This is the hard-stop the web UI's "Stop session" affordance
relies on. If it regressed to ``send-keys`` (the interrupt path)
the session would merely have its current response cancelled and
stay alive, so the stop button would silently fail to terminate
the session. A single kill-session call on the advertised target
is the only thing that ends the ``claude`` process.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="main",
)
captured: list[list[str]] = []
class _FakeCompleted:
"""Fake CompletedProcess returned by the patched subprocess.run."""
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd: list[str], **kwargs: object) -> _FakeCompleted:
"""
Record the tmux command argv list.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: A fake CompletedProcess with rc=0.
"""
del kwargs
captured.append(cmd)
return _FakeCompleted()
monkeypatch.setattr("subprocess.run", _fake_run)
kill_session(bridge_dir)
# Exactly one tmux call: kill-session on the advertised target.
# If 0, the kill was skipped; if it carried send-keys/-l/a key
# name, it's the interrupt path and wouldn't terminate the pane.
assert len(captured) == 1, f"Expected 1 tmux kill-session call, got {len(captured)}."
assert captured[0] == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"kill-session",
"-t",
"main",
]
def test_kill_session_raises_when_tmux_target_never_published(
tmp_path: Path,
) -> None:
"""
kill_session fails loud if tmux.json was never written.
The runner handler catches RuntimeError and returns 503 (best
effort — a missing target means there is no live session to
kill). Swallowing it silently would make the stop button appear
to work while doing nothing.
"""
with pytest.raises(RuntimeError, match="tmux target is not advertised"):
kill_session(tmp_path / "bridge", timeout_s=0.0)
@pytest.mark.parametrize(
"stderr",
[
"no server running on /tmp/example/tmux.sock",
"can't find session: main",
],
)
def test_kill_session_is_idempotent_when_tmux_is_absent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
stderr: str,
) -> None:
"""A stale tmux advertisement still counts as a successful stop."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="main",
)
class _FakeCompleted:
"""Fake CompletedProcess returning a non-zero exit code."""
returncode = 1
stdout = ""
def _fake_run(cmd: list[str], **kwargs: object) -> _FakeCompleted:
"""Return a fake non-zero CompletedProcess."""
del cmd, kwargs
result = _FakeCompleted()
result.stderr = stderr
return result
monkeypatch.setattr("subprocess.run", _fake_run)
kill_session(bridge_dir)
def test_kill_session_raises_on_unexpected_tmux_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unexpected tmux failures remain visible to the stop caller."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="main",
)
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
del cmd, kwargs
return SimpleNamespace(returncode=1, stdout="", stderr="permission denied")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="permission denied"):
kill_session(bridge_dir)
def test_inject_slash_command_clears_draft_pastes_literal_then_enter(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Slash commands flow via C-u + literal paste + Enter.
C-u kills any draft the user is mid-typing — otherwise the paste
concatenates and Enter submits ``<draft>/effort high``. ``-l`` is
required so tmux pastes ``/`` and spaces literally; Enter submits.
Every capture here is blank, so this also pins the fail-soft path:
a never-identifiable draft still gets the single blind submit.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
captured: list[list[str]] = []
class _FakeCompleted:
"""Fake CompletedProcess returned by the patched subprocess.run."""
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd: list[str], **kwargs: object) -> _FakeCompleted:
"""Record one tmux invocation; return rc=0."""
del kwargs
captured.append(cmd)
return _FakeCompleted()
monkeypatch.setattr("subprocess.run", _fake_run)
monkeypatch.setattr(claude_native_bridge, "time", _VirtualClock())
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
# Three keystroke calls in order: C-u (clear), literal paste, Enter.
# 2 = C-u was dropped (draft can concatenate); 4+ = duplicated send.
# capture-pane calls (the delivery-verification polls) are not keystrokes.
keystrokes = [cmd for cmd in captured if "send-keys" in cmd]
assert len(keystrokes) == 3, (
f"Expected 3 tmux send-keys calls (C-u + paste + Enter), got {len(keystrokes)}."
)
clear, paste, submit = keystrokes
assert clear == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"send-keys",
"-t",
"claude:0.0",
"C-u",
]
assert paste == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"send-keys",
"-l",
"-t",
"claude:0.0",
"/effort high",
]
assert submit == [
"tmux",
"-S",
"/tmp/example/tmux.sock",
"send-keys",
"-t",
"claude:0.0",
"Enter",
]
@pytest.mark.parametrize(
"bad_command",
[
"",
"effort high", # missing leading slash
"/effort high\nrm -rf /", # multi-line
],
)
def test_inject_slash_command_rejects_invalid_commands(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
bad_command: str,
) -> None:
"""
Malformed slash commands raise ValueError before touching tmux.
No leading ``/`` would type as plain text; a stray newline would
chain a second command. Validation must fire before the tmux call
so the route returns 4xx instead of silently appearing to succeed.
"""
# No tmux.json published — validation must reject before the
# wait-for-tmux path, proving the check is up front.
bridge_dir = tmp_path / "bridge"
def _fake_run(cmd: list[str], **kwargs: object) -> object:
"""Fail the test if tmux is invoked for an invalid command."""
del cmd, kwargs
raise AssertionError("subprocess.run must not be called for invalid commands")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(ValueError):
claude_native_bridge.inject_slash_command(bridge_dir, command=bad_command)
def test_cycleable_permission_modes_match_the_server_patch_vocabulary() -> None:
"""
The bridge and the Sessions API accept the same set of modes.
PATCH validates ``permission_mode`` against its own frozenset before
forwarding, so a mode in one set but not the other is either
unreachable (rejected at the API after the UI offered it) or
reachable but un-offerable. Pin them together.
"""
from omnigent.server.routes._sessions.common import _CLAUDE_NATIVE_PERMISSION_MODES
assert set(claude_native_bridge.CYCLEABLE_PERMISSION_MODES) == set(
_CLAUDE_NATIVE_PERMISSION_MODES
), "Bridge cycleable modes and the Sessions API's accepted modes must match."
class _FakeModeCycleTmux:
"""
Stand-in tmux whose pane renders Claude's permission-mode footer.
``BTab`` advances a cycle of modes; ``capture-pane`` renders the
current one exactly as Claude Code's TUI does, so the cycler under
test reads real footer text. Construct with a cycle that omits
``auto`` to model an account where the mode is unreachable.
"""
def __init__(
self,
cycle: list[str],
*,
start: str = "default",
stale_reads: int = 0,
) -> None:
self.cycle = cycle
self.index = cycle.index(start)
self.presses = 0
# Captures right after a BTab that still render the PREVIOUS mode,
# modelling the TUI's asynchronous repaint.
self.stale_reads = stale_reads
self._pending_stale = 0
self._prev_index = self.index
_FOOTERS = {
"default": "⏸ manual mode on",
"acceptEdits": "⏵⏵ accept edits on (shift+tab to cycle)",
"plan": "⏸ plan mode on (shift+tab to cycle)",
"auto": "⏵⏵ auto mode on (shift+tab to cycle)",
"bypassPermissions": "⏵⏵ bypass permissions on (shift+tab to cycle)",
}
def run(self, cmd: list[str], **kwargs: object) -> object:
"""Serve capture-pane / send-keys like the real tmux binary."""
del kwargs
if "capture-pane" in cmd:
# Serve the pre-keystroke mode for the first `stale_reads`
# captures after a press, the way a mid-repaint pane does.
index = self.index
if self._pending_stale > 0:
self._pending_stale -= 1
index = self._prev_index
# Include the input box so the readiness gate passes, then
# the status + mode footer rows Claude renders beneath it.
pane = (
"╭──────────────╮\n"
" \n"
"╰──────────────╯\n"
" Opus 5 │ 0/1M (0%)\n"
f" {self._FOOTERS[self.cycle[index]]}\n"
)
return SimpleNamespace(returncode=0, stdout=pane, stderr="")
if cmd[-1] == "BTab":
self.presses += 1
self._prev_index = self.index
self.index = (self.index + 1) % len(self.cycle)
self._pending_stale = self.stale_reads
return SimpleNamespace(returncode=0, stdout="", stderr="")
@pytest.mark.parametrize(
"target",
["auto", "plan", "acceptEdits", "default"],
)
def test_set_permission_mode_cycles_until_target_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
target: str,
) -> None:
"""
Every cycle-reachable mode is reached by pressing shift+tab.
Claude Code has no non-interactive mode command, so the switch
walks its shift+tab cycle and reads the pane footer to confirm
where it landed.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
fake = _FakeModeCycleTmux(["default", "acceptEdits", "plan", "auto"])
monkeypatch.setattr("subprocess.run", fake.run)
got = claude_native_bridge.set_permission_mode(bridge_dir, mode=target, timeout_s=5.0)
assert got == target, f"Expected the pane to settle on {target!r}, got {got!r}."
def test_set_permission_mode_waits_out_a_stale_mode_footer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A pane still showing the old mode doesn't stall the walk.
Claude repaints the footer asynchronously, so a capture taken right
after shift+tab can still render the PREVIOUS mode. Treating that as
the landed mode makes the cycler think the keystroke did nothing: it
keeps pressing while reading one mode behind, and a reachable target
(e.g. ``auto``, the last mode in the cycle) is reported unreachable.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
# Two stale captures per press — enough that a reader accepting the
# first non-empty footer always lags a mode behind.
fake = _FakeModeCycleTmux(
["default", "acceptEdits", "plan", "auto"],
stale_reads=2,
)
monkeypatch.setattr("subprocess.run", fake.run)
got = claude_native_bridge.set_permission_mode(bridge_dir, mode="auto", timeout_s=5.0)
assert got == "auto", f"Expected the walk to reach auto despite stale reads, got {got!r}."
# 3 presses walk default → acceptEdits → plan → auto. More means the
# cycler was re-pressing on stale reads and lapped past the target.
assert fake.presses == 3, f"Expected 3 presses to reach auto, got {fake.presses}."
def test_set_permission_mode_is_a_noop_when_already_in_target(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Re-requesting the current mode sends no keystrokes.
Without this the cycler would lap the whole cycle to return to
where it started, flashing every intermediate mode on the pane.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
fake = _FakeModeCycleTmux(["default", "acceptEdits", "plan", "auto"], start="auto")
monkeypatch.setattr("subprocess.run", fake.run)
got = claude_native_bridge.set_permission_mode(bridge_dir, mode="auto", timeout_s=5.0)
assert got == "auto"
assert fake.presses == 0, f"Expected no shift+tab presses, got {fake.presses}."
def test_set_permission_mode_raises_when_target_not_in_cycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
An unreachable mode fails loud instead of landing somewhere else.
``auto`` is only in the cycle for accounts that have the mode. A
fixed press count would silently leave the session in whatever mode
it happened to land on, so the cycler gives up and reports the modes
it actually saw.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
# No ``auto`` in this session's cycle.
fake = _FakeModeCycleTmux(["default", "acceptEdits", "plan"])
monkeypatch.setattr("subprocess.run", fake.run)
with pytest.raises(RuntimeError, match="not available in this session's cycle"):
claude_native_bridge.set_permission_mode(bridge_dir, mode="auto", timeout_s=5.0)
@pytest.mark.parametrize("bad_mode", ["dontAsk", "bypassPermissions", "", "nonsense"])
def test_set_permission_mode_rejects_non_cycleable_modes(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
bad_mode: str,
) -> None:
"""
Modes shift+tab can't reach are rejected before touching tmux.
``dontAsk`` is never in the cycle and ``bypassPermissions`` only
joins it when the session launched into it — cycling toward either
would wander through unrelated modes and then fail.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
def _fake_run(cmd: list[str], **kwargs: object) -> object:
"""Fail the test if tmux is invoked for a rejected mode."""
del cmd, kwargs
raise AssertionError("subprocess.run must not be called for a non-cycleable mode")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(ValueError, match="cannot be switched on a running session"):
claude_native_bridge.set_permission_mode(bridge_dir, mode=bad_mode, timeout_s=5.0)
def test_set_permission_mode_raises_when_footer_never_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A pane with no mode footer fails loud rather than blind-cycling.
Reading the current mode is what makes the walk deterministic; with
no footer the cycler cannot know where it started, so pressing
shift+tab would move the session to an unknown mode.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
def _fake_run(cmd: list[str], **kwargs: object) -> object:
"""Render a mounted input box that never shows a mode footer."""
del kwargs
if "capture-pane" in cmd:
pane = "╭────────╮\n \n╰────────╯\n Opus 5 │ 0/1M (0%)\n"
return SimpleNamespace(returncode=0, stdout=pane, stderr="")
if cmd[-1] == "BTab":
raise AssertionError("must not cycle when the current mode is unknown")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="did not render a permission-mode footer"):
claude_native_bridge.set_permission_mode(bridge_dir, mode="auto", timeout_s=5.0)
def test_set_permission_mode_raises_when_tmux_target_never_published(
tmp_path: Path,
) -> None:
"""
Missing tmux.json surfaces RuntimeError, mirroring inject_slash_command.
The runner route catches it and returns 503 so the web UI can show
"couldn't switch mode" instead of claiming a switch that never
reached the pane.
"""
with pytest.raises(RuntimeError, match="tmux target is not advertised"):
claude_native_bridge.set_permission_mode(
tmp_path / "bridge",
mode="auto",
timeout_s=0.0,
)
def test_permission_mode_from_pane_reads_the_footer_below_the_input_box() -> None:
"""
The footer is found beneath the box rule, not at a fixed tail offset.
A tall footer (extra status rows, concurrent subagent lines) pushes the
mode line further from the bottom than a fixed window would reach, so a
tail-only scan would report "unknown" and the picker would hide.
"""
pane = (
"● Working on it\n"
"╭──────────────╮\n"
" \n"
"╰──────────────╯\n"
" Opus 5 │ 0/1M (0%)\n"
" ⏵⏵ auto mode on (shift+tab to cycle)\n"
" ↓ 2 subagents running\n"
" ⧉ In omnigent\n"
)
assert claude_native_bridge._permission_mode_from_pane(pane) == "auto"
def test_permission_mode_from_pane_ignores_a_mode_named_in_the_transcript() -> None:
"""
Transcript text above the input box can't be misread as the live mode.
Claude echoing "plan mode on" while *discussing* modes sits above the
box rule; only the region below it is the live footer. Without the
anchor the UI would flip to Plan because the agent said the words.
"""
pane = (
"● You asked about plan mode on vs auto mode on — here's how they differ.\n"
"╭──────────────╮\n"
" \n"
"╰──────────────╯\n"
" ⏸ manual mode on\n"
)
assert claude_native_bridge._permission_mode_from_pane(pane) == "default"
def test_permission_mode_from_pane_returns_none_without_a_footer() -> None:
"""
A footerless pane reads as unknown, never as a guessed default.
The forwarder treats ``None`` as "no fresh observation" so a mid-repaint
capture can't post a spurious switch, and the web picker hides instead
of showing a mode the session may not be in.
"""
assert claude_native_bridge._permission_mode_from_pane("") is None
assert (
claude_native_bridge._permission_mode_from_pane(
"╭────────╮\n \n╰────────╯\n Opus 5 │ 0/1M (0%)\n"
)
is None
)
def test_read_permission_mode_reports_the_pane_mode(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``read_permission_mode`` captures the pane and returns its footer mode.
This is the forwarder's only window onto an in-TUI shift+tab: Claude Code
emits no event on a mode change, so the rendered footer is the signal.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
def _fake_run(cmd: list[str], **kwargs: object) -> object:
"""Render a pane sitting in plan mode."""
del kwargs
if "capture-pane" in cmd:
pane = "╭────────╮\n \n╰────────╯\n ⏸ plan mode on (shift+tab to cycle)\n"
return SimpleNamespace(returncode=0, stdout=pane, stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
assert claude_native_bridge.read_permission_mode(bridge_dir) == "plan"
def test_read_permission_mode_returns_none_without_a_tmux_target(tmp_path: Path) -> None:
"""
No published tmux.json reads as unknown instead of raising.
The forwarder calls this every poll, including before the terminal is up.
Raising would have to be swallowed by the caller's poll loop; returning
``None`` keeps "terminal not ready" and "no footer" on one quiet path.
"""
assert claude_native_bridge.read_permission_mode(tmp_path / "bridge") is None
def test_inject_slash_command_raises_when_tmux_target_never_published(
tmp_path: Path,
) -> None:
"""
Missing tmux.json surfaces RuntimeError, mirroring inject_user_message.
The runner route catches and returns 503, so AP's PATCH still
succeeds (effort persisted) and the next spawn picks it up.
"""
with pytest.raises(RuntimeError, match="tmux target is not advertised"):
claude_native_bridge.inject_slash_command(
tmp_path / "bridge",
command="/effort high",
timeout_s=0.0,
)
@pytest.mark.asyncio
async def test_channel_server_relays_active_omnigent_tools(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
subprocess_bridge_root: Path,
) -> None:
"""
Active turn tools are advertised to Claude and dispatched through AP.
This fails if Claude Code can receive web-channel inputs but cannot
call the Omnigent tools made available to the server-side agent.
"""
monkeypatch.setattr(
"omnigent.claude_native_bridge._TRUSTED_PARENT",
Path(tempfile.gettempdir()),
)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root)
bridge_dir = prepare_bridge_dir("conv_tools", workspace=tmp_path)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
calls: list[dict[str, object]] = []
async def tool_executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""
Capture one relayed tool call.
:param name: Tool name from Claude's MCP request, e.g.
``"sys_custom"``.
:param arguments: Tool arguments from Claude.
:returns: Structured tool result.
"""
calls.append({"name": name, "arguments": arguments})
return {"echo": arguments}
relay = None
try:
assert proc.stdin is not None
assert proc.stdout is not None
_write_json_line(
proc.stdin,
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
)
initialize = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert initialize["id"] == 1
relay = start_tool_relay(
bridge_dir=bridge_dir,
tools=[
{
"name": "sys_custom",
"description": "Test-only active Omnigent tool.",
"parameters": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
}
],
tool_executor=tool_executor,
loop=asyncio.get_running_loop(),
)
post_tools_changed(bridge_dir)
changed = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert changed["method"] == "notifications/tools/list_changed"
_write_json_line(
proc.stdin,
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
)
listed = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
listed_tools = listed["result"]["tools"]
custom_tools = [tool for tool in listed_tools if tool["name"] == "sys_custom"]
assert custom_tools == [
{
"name": "sys_custom",
"description": "Test-only active Omnigent tool.",
"inputSchema": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
}
]
_write_json_line(
proc.stdin,
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "sys_custom",
"arguments": {"value": "hello"},
},
},
)
tool_result = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert tool_result["id"] == 3
text = tool_result["result"]["content"][0]["text"]
assert json.loads(text) == {"echo": {"value": "hello"}}
assert calls == [{"name": "sys_custom", "arguments": {"value": "hello"}}]
finally:
if relay is not None:
relay.close()
proc.terminate()
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5.0)
def test_call_relay_tool_returns_mcp_error_on_read_timeout(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A relay POST that times out mid-read returns an MCP error, not a raise.
Regression test: ``request.urlopen`` raises a bare ``socket.timeout`` /
``TimeoutError`` (an ``OSError`` subclass that is NOT a ``URLError``) when
the relay server accepts the connection but never responds. The previous
``except error.URLError`` did not catch it, so the exception propagated up
through ``_call_mcp_tool`` → ``_stdio_jsonrpc_loop`` and killed the MCP
server — surfacing to Claude Code as ``-32000: Connection closed``. This
asserts the call instead yields an ``isError`` MCP result so the stdio
loop can keep serving.
"""
started = threading.Event()
release = threading.Event()
class _HangingHandler(BaseHTTPRequestHandler):
"""HTTP handler that accepts the request then stalls past the timeout."""
def log_message(self, format: str, *args: Any) -> None:
"""
Silence the default per-request stderr logging.
:param format: Unused format string.
:param args: Unused format args.
:returns: None.
"""
del format, args
def do_POST(self) -> None:
"""
Read the request body, then block without replying.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
self.rfile.read(length)
started.set()
# Hold the connection open with no response so the client's read
# times out. Released in the test's finally so the thread exits.
release.wait(timeout=10.0)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _HangingHandler)
host, port = httpd.server_address
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
server_thread.start()
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
claude_native_bridge._write_json_file(
bridge_dir / claude_native_bridge._TOOL_RELAY_FILE,
{"url": f"http://{host}:{port}", "token": "relay-token", "tools": []},
)
# Force a sub-second read timeout so the test is fast; the production
# value is minutes. The point under test is the exception TYPE handling,
# not the duration.
monkeypatch.setattr(claude_native_bridge, "_TOOL_RELAY_POST_TIMEOUT_S", 0.3)
try:
result = claude_native_bridge._call_relay_tool(
bridge_dir, "sys_os_shell", {"command": "echo hi"}
)
finally:
release.set()
httpd.shutdown()
httpd.server_close()
server_thread.join(timeout=5.0)
assert started.is_set(), "relay server never received the POST"
# The result is a well-formed MCP error result, NOT a raised exception.
assert result["isError"] is True
error_text = json.loads(result["content"][0]["text"])["error"]
assert "failed to call Omnigent tool relay" in error_text
@pytest.mark.asyncio
async def test_serve_mcp_survives_handler_exception_and_keeps_serving(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
subprocess_bridge_root: Path,
) -> None:
"""
An exception handling one request becomes -32603 and the server lives.
Regression test for the bridge crash: an *uncaught* exception while
handling ONE ``tools/call`` must not tear down the long-lived stdio MCP
server. The relay backend returns invalid UTF-8 bytes, so
``_call_relay_tool``'s ``resp.read().decode("utf-8")`` raises
``UnicodeDecodeError`` — a ``ValueError`` that is NOT an ``OSError``, so
it escapes the ``except OSError`` clause and propagates out of
``_handle_mcp_request``. The loop's per-request guard must convert it into
a JSON-RPC ``-32603`` error for the offending call, and a SUBSEQUENT
``tools/list`` on the same process must still succeed — proving the server
did not exit (which Claude would otherwise see as
``-32000: Connection closed``). Without the guard, the decode error kills
``_serve_mcp`` and the ``tools/list`` read below times out.
"""
monkeypatch.setattr(
"omnigent.claude_native_bridge._TRUSTED_PARENT",
Path(tempfile.gettempdir()),
)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root)
bridge_dir = prepare_bridge_dir("conv_crash", workspace=tmp_path)
# A relay server that returns HTTP 200 with invalid UTF-8 bytes. This
# makes _call_relay_tool's resp.read().decode("utf-8") raise
# UnicodeDecodeError (a ValueError, NOT an OSError), which the function's
# except OSError does not catch — so the exception propagates up to the
# stdio loop and exercises the per-request guard under test. The relay
# tool name must appear in tool_relay.json so _call_mcp_tool routes to
# _call_relay_tool.
class _InvalidUtf8Handler(BaseHTTPRequestHandler):
"""HTTP handler that replies 200 with an undecodable body."""
def log_message(self, format: str, *args: Any) -> None:
"""
Silence the default per-request stderr logging.
:param format: Unused format string.
:param args: Unused format args.
:returns: None.
"""
del format, args
def do_POST(self) -> None:
"""
Reply with bytes that are not valid UTF-8.
:returns: None.
"""
length = int(self.headers.get("Content-Length", "0"))
self.rfile.read(length)
# 0xFF/0xFE are never valid UTF-8 lead bytes → decode() raises.
body = b"\xff\xfe\xfa"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _InvalidUtf8Handler)
host, port = httpd.server_address
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
server_thread.start()
claude_native_bridge._write_json_file(
bridge_dir / claude_native_bridge._TOOL_RELAY_FILE,
{
"url": f"http://{host}:{port}",
"token": "relay-token",
"tools": [
{
"name": "sys_flaky",
"description": "Relay tool whose backend returns undecodable bytes.",
"parameters": {"type": "object", "properties": {}},
}
],
},
)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
assert proc.stdin is not None
assert proc.stdout is not None
_write_json_line(
proc.stdin,
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
)
initialize = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert initialize["id"] == 1
# Call the flaky relay tool. Decoding the undecodable response raises
# UnicodeDecodeError, which escapes _call_relay_tool's except OSError
# and is caught by the loop's per-request guard.
_write_json_line(
proc.stdin,
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "sys_flaky", "arguments": {}},
},
)
call_result = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert call_result["id"] == 2
# The guard reports the uncaught exception as a JSON-RPC -32603
# ("Internal error") response — NOT a normal MCP error result. This is
# the assertion that fails if the guard is removed (the call would get
# no response because the server would have exited).
assert call_result["error"]["code"] == -32603
assert "result" not in call_result
# The decisive assertion: the server is STILL ALIVE and answers the
# next request. Before the per-request loop guard, the exception above
# would have exited the process and this read would time out.
_write_json_line(
proc.stdin,
{"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}},
)
listed = await asyncio.to_thread(_read_json_line, proc.stdout, timeout_s=5.0)
assert listed["id"] == 3
assert "tools" in listed["result"]
finally:
httpd.shutdown()
httpd.server_close()
server_thread.join(timeout=5.0)
proc.terminate()
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5.0)
@pytest.mark.parametrize("concurrent_method", ["ping", "tools/call"])
def test_serve_mcp_answers_while_relay_call_is_blocked(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
concurrent_method: str,
) -> None:
"""A slow relay call must not stop the stdio server from serving peers."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
blocked_call_started = threading.Event()
release_blocked_call = threading.Event()
requests: queue.Queue[bytes] = queue.Queue()
responses: queue.Queue[dict[str, Any]] = queue.Queue()
class _QueuedStdin:
"""Minimal binary stdin stream driven by a queue."""
buffer: _QueuedStdin
def __init__(self) -> None:
self.buffer = self
def readline(self) -> bytes:
return requests.get(timeout=5.0)
def _write_request(payload: dict[str, object]) -> None:
requests.put((json.dumps(payload) + "\n").encode("utf-8"))
def _call_relay_tool(
bridge_dir: Path,
name: str,
arguments: dict[str, Any],
) -> dict[str, Any]:
del bridge_dir, arguments
if name == "sys_slow":
blocked_call_started.set()
release_blocked_call.wait(timeout=10.0)
return {"content": [{"type": "text", "text": "slow-done"}]}
return {"content": [{"type": "text", "text": "fast-done"}]}
monkeypatch.setattr(claude_native_bridge.sys, "stdin", _QueuedStdin())
monkeypatch.setattr(
claude_native_bridge, "_read_relay_tool_names", lambda _path: {"sys_slow", "sys_fast"}
)
monkeypatch.setattr(claude_native_bridge, "_call_relay_tool", _call_relay_tool)
monkeypatch.setattr(
claude_native_bridge,
"_write_jsonrpc",
lambda payload, _lock, **_kwargs: responses.put(payload),
)
server_thread = threading.Thread(
target=claude_native_bridge._stdio_jsonrpc_loop,
args=({}, threading.Lock(), bridge_dir),
daemon=True,
)
server_thread.start()
try:
_write_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "sys_slow", "arguments": {}},
}
)
assert blocked_call_started.wait(timeout=5.0)
if concurrent_method == "ping":
concurrent_request: dict[str, object] = {
"jsonrpc": "2.0",
"id": 2,
"method": "ping",
"params": {},
}
else:
concurrent_request = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "sys_fast", "arguments": {}},
}
_write_request(concurrent_request)
concurrent_response = responses.get(timeout=1.0)
assert concurrent_response["id"] == 2
if concurrent_method == "ping":
assert concurrent_response["result"] == {}
else:
assert concurrent_response["result"]["content"][0]["text"] == "fast-done"
release_blocked_call.set()
blocked_response = responses.get(timeout=5.0)
assert blocked_response["id"] == 1
finally:
release_blocked_call.set()
requests.put(b"")
server_thread.join(timeout=5.0)
def test_serve_mcp_rejects_at_concurrency_cap_and_keeps_serving(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A saturated request cap rejects work without stopping the server."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
blocked_call_started = threading.Event()
release_blocked_call = threading.Event()
requests: queue.Queue[bytes] = queue.Queue()
responses: queue.Queue[dict[str, Any]] = queue.Queue()
class _QueuedStdin:
"""Minimal binary stdin stream driven by a queue."""
buffer: _QueuedStdin
def __init__(self) -> None:
self.buffer = self
def readline(self) -> bytes:
return requests.get(timeout=5.0)
def _write_request(request_id: int) -> None:
requests.put(
(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "ping"}) + "\n").encode()
)
def _handle_mcp_request(
method: str,
params: object,
tools: dict[str, object],
request_bridge_dir: Path,
) -> dict[str, object]:
del method, params, tools, request_bridge_dir
if not blocked_call_started.is_set():
blocked_call_started.set()
release_blocked_call.wait(timeout=10.0)
return {}
monkeypatch.setattr(claude_native_bridge, "_MAX_CONCURRENT_MCP_REQUESTS", 1)
monkeypatch.setattr(claude_native_bridge.sys, "stdin", _QueuedStdin())
monkeypatch.setattr(claude_native_bridge, "_handle_mcp_request", _handle_mcp_request)
monkeypatch.setattr(
claude_native_bridge,
"_write_jsonrpc",
lambda payload, _lock, **_kwargs: responses.put(payload),
)
server_thread = threading.Thread(
target=claude_native_bridge._stdio_jsonrpc_loop,
args=({}, threading.Lock(), bridge_dir),
daemon=True,
)
server_thread.start()
try:
_write_request(1)
assert blocked_call_started.wait(timeout=5.0)
_write_request(2)
rejected = responses.get(timeout=1.0)
assert rejected == {
"jsonrpc": "2.0",
"id": 2,
"error": {"code": -32000, "message": "server busy"},
}
release_blocked_call.set()
assert responses.get(timeout=5.0)["id"] == 1
for request_thread in threading.enumerate():
if request_thread.name == "claude-native-mcp-request":
request_thread.join(timeout=5.0)
assert not request_thread.is_alive()
_write_request(3)
assert responses.get(timeout=5.0) == {"jsonrpc": "2.0", "id": 3, "result": {}}
finally:
release_blocked_call.set()
requests.put(b"")
server_thread.join(timeout=5.0)
def test_serve_mcp_rejects_when_request_thread_cannot_start(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A request-thread start failure rejects calls without stopping the server."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
requests: queue.Queue[bytes] = queue.Queue()
responses: queue.Queue[dict[str, Any]] = queue.Queue()
class _QueuedStdin:
"""Minimal binary stdin stream driven by a queue."""
buffer: _QueuedStdin
def __init__(self) -> None:
self.buffer = self
def readline(self) -> bytes:
return requests.get(timeout=5.0)
real_thread = threading.Thread
class _FailingThread(real_thread):
"""Request thread whose start simulates OS resource exhaustion."""
def start(self) -> None:
raise RuntimeError("can't start new thread")
def _write_request(request_id: int) -> None:
requests.put(
(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "ping"}) + "\n").encode()
)
monkeypatch.setattr(claude_native_bridge, "_MAX_CONCURRENT_MCP_REQUESTS", 1)
monkeypatch.setattr(claude_native_bridge.sys, "stdin", _QueuedStdin())
monkeypatch.setattr(claude_native_bridge.threading, "Thread", _FailingThread)
monkeypatch.setattr(
claude_native_bridge,
"_write_jsonrpc",
lambda payload, _lock, **_kwargs: responses.put(payload),
)
server_thread = real_thread(
target=claude_native_bridge._stdio_jsonrpc_loop,
args=({}, threading.Lock(), bridge_dir),
daemon=True,
)
server_thread.start()
try:
for request_id in (1, 2):
_write_request(request_id)
assert responses.get(timeout=5.0) == {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32000, "message": "server busy"},
}
finally:
requests.put(b"")
server_thread.join(timeout=5.0)
@pytest.mark.asyncio
async def test_start_tool_relay_accepts_pi_native_bridge_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Relay startup accepts Pi-native's persistent bridge root."""
from omnigent import pi_native_bridge
pi_root = tmp_path / ".omnigent" / "pi-native"
monkeypatch.setattr("omnigent.pi_native_bridge._BRIDGE_ROOT", pi_root)
bridge_dir = pi_native_bridge.prepare_bridge_dir("conv_pi")
relay_file = bridge_dir / claude_native_bridge._TOOL_RELAY_FILE
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""Return an empty result for the unused relay tool callback."""
del name, arguments
return {}
relay = None
try:
relay = start_tool_relay(
bridge_dir=bridge_dir,
tools=[
{
"name": "sys_session_list",
"description": "List Omnigent sessions.",
"parameters": {"type": "object", "properties": {}},
}
],
tool_executor=_executor,
loop=asyncio.get_running_loop(),
)
assert relay_file.exists(), (
"Pi-native relay did not write tool_relay.json under the persistent bridge root"
)
relay_info = json.loads(relay_file.read_text(encoding="utf-8"))
assert relay_info["tools"][0]["name"] == "sys_session_list"
finally:
if relay is not None:
relay.close()
@pytest.mark.asyncio
async def test_start_tool_relay_accepts_codex_native_bridge_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Relay startup accepts Codex-native's persistent bridge root.
Codex-native reuses the Claude MCP relay but stores bridge files in
``~/.omnigent/codex-native`` instead of Claude's ``/tmp`` bridge
root. A regression here logs "Failed to start comment relay" and
leaves Codex without comment/session tools.
:param tmp_path: Pytest temp directory used as an isolated user
state parent.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
from omnigent import codex_native_bridge
codex_root = tmp_path / ".omnigent" / "codex-native"
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", codex_root)
bridge_dir = codex_native_bridge.prepare_bridge_dir("conv_codex")
relay_file = bridge_dir / claude_native_bridge._TOOL_RELAY_FILE
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""
Return an empty result for the unused relay tool callback.
:param name: Tool name, e.g. ``"list_comments"``.
:param arguments: Tool arguments, e.g. ``{"status": "pending"}``.
:returns: Empty tool result.
"""
del name, arguments
return {}
relay = None
try:
relay = start_tool_relay(
bridge_dir=bridge_dir,
tools=[
{
"name": "list_comments",
"description": "List comments.",
"parameters": {"type": "object", "properties": {}},
}
],
tool_executor=_executor,
loop=asyncio.get_running_loop(),
)
assert relay_file.exists(), (
"Codex-native relay did not write tool_relay.json under the persistent bridge root"
)
relay_info = json.loads(relay_file.read_text(encoding="utf-8"))
assert relay_info["tools"][0]["name"] == "list_comments"
finally:
if relay is not None:
relay.close()
@pytest.mark.asyncio
async def test_start_tool_relay_accepts_antigravity_native_bridge_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Relay startup accepts Antigravity-native's persistent bridge root (#1194).
Antigravity-native reuses the Claude MCP relay but stores bridge files in
``~/.omnigent/antigravity-native`` (the same ``$HOME/.omnigent/<harness>``
shape codex uses). A regression in :func:`_trusted_parent_for_bridge_dir`
would reject the bridge dir, the relay would fail to write
``tool_relay.json``, and the wrapped agy would get no ``sys_*`` tools.
:param tmp_path: Pytest temp directory used as an isolated user
state parent.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
from omnigent import antigravity_native_bridge
antigravity_root = tmp_path / ".omnigent" / "antigravity-native"
monkeypatch.setattr("omnigent.antigravity_native_bridge._BRIDGE_ROOT", antigravity_root)
bridge_dir = antigravity_native_bridge.prepare_bridge_dir("conv_agy")
relay_file = bridge_dir / claude_native_bridge._TOOL_RELAY_FILE
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""
Return an empty result for the unused relay tool callback.
:param name: Tool name, e.g. ``"sys_session_list"``.
:param arguments: Tool arguments.
:returns: Empty tool result.
"""
del name, arguments
return {}
relay = None
try:
relay = start_tool_relay(
bridge_dir=bridge_dir,
tools=[
{
"name": "sys_session_create",
"description": "Spawn an Omnigent sub-agent session.",
"parameters": {"type": "object", "properties": {}},
}
],
tool_executor=_executor,
loop=asyncio.get_running_loop(),
)
assert relay_file.exists(), (
"Antigravity-native relay did not write tool_relay.json under the "
"persistent bridge root"
)
relay_info = json.loads(relay_file.read_text(encoding="utf-8"))
assert relay_info["tools"][0]["name"] == "sys_session_create"
finally:
if relay is not None:
relay.close()
@pytest.mark.asyncio
async def test_start_tool_relay_accepts_opencode_native_bridge_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Relay startup accepts OpenCode-native's persistent bridge root.
opencode-native reuses the Claude MCP relay but stores bridge files in
``~/.omnigent/opencode-native`` (the same ``$HOME/.omnigent/<harness>``
shape codex/antigravity use). The missing allowlist entry made ``serve-mcp``
crash on startup (``_ensure_secure_dir`` → "not under an allowed bridge
root"), which opencode surfaced as ``MCP error -32000: Connection closed``
and the wrapped opencode got no ``sys_*`` tools. Guards the regression.
:param tmp_path: Pytest temp directory used as an isolated user state parent.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
from omnigent import opencode_native_bridge
opencode_root = tmp_path / ".omnigent" / "opencode-native"
monkeypatch.setattr("omnigent.opencode_native_bridge._BRIDGE_ROOT", opencode_root)
bridge_dir = opencode_native_bridge.prepare_bridge_dir("conv_oc")
relay_file = bridge_dir / claude_native_bridge._TOOL_RELAY_FILE
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""Return an empty result for the unused relay tool callback."""
del name, arguments
return {}
relay = None
try:
relay = start_tool_relay(
bridge_dir=bridge_dir,
tools=[
{
"name": "sys_session_list",
"description": "List Omnigent sessions.",
"parameters": {"type": "object", "properties": {}},
}
],
tool_executor=_executor,
loop=asyncio.get_running_loop(),
)
assert relay_file.exists(), (
"OpenCode-native relay did not write tool_relay.json under the persistent bridge root"
)
relay_info = json.loads(relay_file.read_text(encoding="utf-8"))
assert relay_info["tools"][0]["name"] == "sys_session_list"
finally:
if relay is not None:
relay.close()
@pytest.mark.asyncio
async def test_relay_close_keeps_advertisement_owned_by_newer_relay(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
subprocess_bridge_root: Path,
) -> None:
"""
Closing a superseded relay must not delete a newer relay's advertisement.
Fork/clear/resume sessions keep the same ``bridge_id`` — hence the same
bridge dir and ``tool_relay.json``. When a newer session starts its relay
it overwrites the file with its own token; closing the older session's
relay (e.g. on session delete) must recognise it no longer owns the file
and leave it in place. Unconditional unlinking here would erase the
still-active newer session's ``list_comments`` / ``update_comment``.
"""
# gettempdir(), not a literal /tmp: the fixture root lives under the
# platform temp dir (/var/folders/… on macOS), which /tmp never contains.
monkeypatch.setattr(
"omnigent.claude_native_bridge._TRUSTED_PARENT", Path(tempfile.gettempdir())
)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root)
bridge_dir = prepare_bridge_dir("conv_shared_bridge", workspace=tmp_path)
relay_file = bridge_dir / claude_native_bridge._TOOL_RELAY_FILE
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""
Unused executor — this test only exercises advertisement lifecycle.
:param name: Tool name (unused).
:param arguments: Tool arguments (unused).
:returns: Empty result dict.
"""
del name, arguments
return {}
tools = [
{
"name": "list_comments",
"description": "",
"parameters": {"type": "object", "properties": {}},
}
]
loop = asyncio.get_running_loop()
old_relay = None
new_relay = None
try:
old_relay = start_tool_relay(
bridge_dir=bridge_dir, tools=tools, tool_executor=_executor, loop=loop
)
old_token = json.loads(relay_file.read_text())["token"]
# A second relay on the same bridge dir overwrites the advertisement
# with its own token — the fork/clear/resume reuse of one bridge_id.
new_relay = start_tool_relay(
bridge_dir=bridge_dir, tools=tools, tool_executor=_executor, loop=loop
)
new_token = json.loads(relay_file.read_text())["token"]
# Tokens are random per relay; equality would mean start_tool_relay did
# not rebind, collapsing the two-relay premise of this test.
assert new_token != old_token, "second relay did not rewrite the token"
# Closing the OLD relay must NOT remove the file the NEW relay owns.
old_relay.close()
assert relay_file.exists(), (
"old relay.close() deleted tool_relay.json now owned by the newer "
"relay — the still-active session would lose its comment tools"
)
# The file still advertises the NEW relay, untouched by the old close.
assert json.loads(relay_file.read_text())["token"] == new_token, (
"old relay.close() mutated the newer relay's advertisement"
)
# The owning relay's close() does remove its own advertisement.
new_relay.close()
assert not relay_file.exists(), (
"new relay.close() left a stale tool_relay.json after the owning relay shut down"
)
finally:
# close() is safe to call twice (shutdown is idempotent once the
# server has stopped); guarantees both sockets are released on failure.
if old_relay is not None:
old_relay.close()
if new_relay is not None:
new_relay.close()
def _write_json_line(handle: TextIO, payload: dict[str, object]) -> None:
"""
Write one JSON-RPC line to a subprocess stdin stream.
:param handle: Text stdin handle returned by
:class:`subprocess.Popen`.
:param payload: JSON-compatible object to write.
:returns: None.
"""
handle.write(json.dumps(payload) + "\n")
handle.flush()
def _read_json_line(handle: TextIO, *, timeout_s: float) -> dict[str, object]:
"""
Read one JSON line from a subprocess stdout stream.
:param handle: Text stdout handle returned by
:class:`subprocess.Popen`.
:param timeout_s: Seconds to wait, e.g. ``5.0``.
:returns: Parsed JSON object.
:raises TimeoutError: If no line is available before the
timeout.
"""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
ready, _write, _error = select.select([handle], [], [], 0.05)
if not ready:
continue
line = handle.readline()
if line:
payload = json.loads(line)
assert isinstance(payload, dict)
return payload
raise TimeoutError("subprocess did not emit a JSON line")
def test_usage_from_transcript_entry_sums_context_tokens() -> None:
"""
Context-token count must sum the three input-side fields.
The "context tokens" exposed to web's input-composer ring is
``input_tokens + cache_creation_input_tokens +
cache_read_input_tokens``. ``output_tokens`` is generated within
the same call and does NOT count toward the next prompt's size,
so it must be reported separately, not folded into the sum.
"""
entry = {
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "ok"}],
"usage": {
"input_tokens": 6,
"cache_creation_input_tokens": 22967,
"cache_read_input_tokens": 21595,
"output_tokens": 554,
},
},
}
usage = claude_native_bridge._usage_from_transcript_entry(entry)
assert usage == {
"context_tokens": 6 + 22967 + 21595,
"input_tokens": 6,
"output_tokens": 554,
"cache_creation_input_tokens": 22967,
"cache_read_input_tokens": 21595,
}
def test_usage_from_transcript_entry_returns_none_for_non_assistant() -> None:
"""User-role entries do not carry ``message.usage``; helper returns ``None``."""
assert (
claude_native_bridge._usage_from_transcript_entry(
{"type": "user", "message": {"role": "user", "content": "hi"}}
)
is None
)
def test_usage_from_transcript_entry_tolerates_missing_cache_fields() -> None:
"""
Old transcript shapes without cache_creation/cache_read still parse.
Treats absent cache fields as zero so the helper degrades gracefully
on transcripts written by Claude versions predating prompt caching.
"""
entry = {
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "ok"}],
"usage": {"input_tokens": 100, "output_tokens": 20},
},
}
usage = claude_native_bridge._usage_from_transcript_entry(entry)
assert usage == {"context_tokens": 100, "input_tokens": 100, "output_tokens": 20}
def test_usage_from_transcript_entry_forwards_cache_keys_for_cost() -> None:
"""
Cache-read and cache-creation token counts must appear in the
returned dict so ``compute_llm_cost`` can bill them at their
cheaper per-token rates.
Without this, cached tokens are invisible to cost computation
and get implicitly billed at the full input rate (or not at all,
depending on how ``input_tokens`` was populated upstream).
"""
entry = {
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "ok"}],
"usage": {
"input_tokens": 50,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000,
"output_tokens": 200,
},
},
}
usage = claude_native_bridge._usage_from_transcript_entry(entry)
# cache_read present, cache_creation absent (zero is omitted).
assert usage is not None
assert usage["cache_read_input_tokens"] == 8000
assert "cache_creation_input_tokens" not in usage
assert usage["input_tokens"] == 50
assert usage["output_tokens"] == 200
assert usage["context_tokens"] == 50 + 8000
def test_read_transcript_items_from_offset_returns_latest_usage(tmp_path: Path) -> None:
"""
The transcript reader exposes the most recent assistant ``usage``.
Two assistant entries are written; the last one's usage must win
so the input-composer ring tracks the *current* prompt size, not a
stale earlier value.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "a1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "one"}],
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 100,
"cache_read_input_tokens": 0,
"output_tokens": 5,
},
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "a2",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "two"}],
"usage": {
"input_tokens": 20,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 200,
"output_tokens": 7,
},
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_usage == {
"context_tokens": 20 + 50 + 200,
"input_tokens": 20,
"output_tokens": 7,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 200,
}
def test_read_transcript_items_from_offset_latest_usage_none_when_user_only(
tmp_path: Path,
) -> None:
"""A batch of user-only entries leaves ``latest_usage`` / ``latest_model`` as ``None``."""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps({"type": "user", "message": {"role": "user", "content": "hi"}}) + "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_usage is None
assert result.latest_model is None
def test_read_transcript_items_from_offset_returns_latest_model(
tmp_path: Path,
) -> None:
"""
The transcript reader exposes ``message.model`` from the latest assistant turn.
Surfaced for diagnostics / future use; the ring's denominator now
comes from the statusLine stdin (see
:func:`read_claude_context_state`), not a model lookup.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "assistant",
"uuid": "a1",
"message": {
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "one"}],
},
}
),
json.dumps(
{
"type": "assistant",
"uuid": "a2",
"message": {
"role": "assistant",
"model": "claude-opus-4-7",
"content": [{"type": "text", "text": "two"}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_model == "claude-opus-4-7"
def test_read_transcript_items_surfaces_custom_title_without_an_item(
tmp_path: Path,
) -> None:
"""
A ``/rename`` record surfaces on ``latest_custom_title`` and renders nothing.
Claude writes the operator's title as a ``custom-title`` metadata
record carrying no ``message``, so it must not become a conversation
item — the forwarder mirrors it onto the session title instead.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"uuid": "u1",
"message": {"role": "user", "content": "hi"},
}
),
json.dumps(
{
"type": "custom-title",
"customTitle": "auth-refactor",
"sessionId": "s1",
}
),
]
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_custom_title == "auth-refactor"
# The rename record itself is metadata, not a user/assistant bubble.
assert [item.item_type for item in result.items] == ["message"]
def test_read_transcript_items_custom_title_last_write_wins(tmp_path: Path) -> None:
"""Renaming twice in one window leaves the most recent title."""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps({"type": "custom-title", "customTitle": "first", "sessionId": "s1"}),
json.dumps({"type": "custom-title", "customTitle": "second", "sessionId": "s1"}),
]
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_custom_title == "second"
def test_read_transcript_items_ignores_ai_title_and_blank_custom_title(
tmp_path: Path,
) -> None:
"""
Claude's own ``aiTitle`` and a blank ``customTitle`` are both ignored.
Omnigent runs its own background titler, so forwarding Claude's
generated title would put two auto-titlers in a fight over one field;
only an explicit ``/rename`` propagates.
"""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
"\n".join(
[
json.dumps(
{
"type": "ai-title",
"aiTitle": "Generated summary of the session",
"sessionId": "s1",
}
),
json.dumps({"type": "custom-title", "customTitle": " ", "sessionId": "s1"}),
]
)
+ "\n",
encoding="utf-8",
)
result = read_transcript_items_from_offset(
transcript_path, 0, start_line=0, agent_name="claude-native-ui"
)
assert result.latest_custom_title is None
assert result.items == []
def test_read_transcript_items_since_surfaces_custom_title(tmp_path: Path) -> None:
"""The legacy line-cursor reader surfaces the rename too."""
transcript_path = tmp_path / "session.jsonl"
transcript_path.write_text(
json.dumps({"type": "custom-title", "customTitle": "auth-refactor", "sessionId": "s1"})
+ "\n",
encoding="utf-8",
)
result = claude_native_bridge.read_transcript_items_since_with_position(
transcript_path, 0, agent_name="claude-native-ui"
)
assert result.latest_custom_title == "auth-refactor"
def test_read_claude_context_state_returns_parsed_payload(tmp_path: Path) -> None:
"""
``context.json`` round-trips into a dict with both fields.
The statusLine wrapper writes this file every Claude Code render
tick; the forwarder reads it on each poll to drive the ring.
Drift here silently breaks the ring on every claude-native chat.
"""
bridge_dir = tmp_path
payload = {
"context_window_size": 1_000_000,
"current_usage": {
"input_tokens": 6,
"cache_creation_input_tokens": 22967,
"cache_read_input_tokens": 21595,
"output_tokens": 554,
},
}
(bridge_dir / "context.json").write_text(json.dumps(payload), encoding="utf-8")
state = claude_native_bridge.read_claude_context_state(bridge_dir)
assert state is not None
assert state["context_window_size"] == 1_000_000
assert state["current_usage"]["input_tokens"] == 6
def test_read_claude_context_state_returns_none_when_missing(tmp_path: Path) -> None:
"""A missing context.json must not raise — forwarder treats as no update."""
assert claude_native_bridge.read_claude_context_state(tmp_path) is None
def test_read_claude_context_state_rejects_non_positive_window(tmp_path: Path) -> None:
"""
A non-positive ``context_window_size`` is dropped.
Defends the ring's denominator: zero would NaN the math, negative
would render a backwards arc. Either is worse than no update.
"""
bridge_dir = tmp_path
(bridge_dir / "context.json").write_text(
json.dumps({"context_window_size": 0}), encoding="utf-8"
)
assert claude_native_bridge.read_claude_context_state(bridge_dir) is None
def test_read_user_status_line_command_returns_string(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
The user's global statusLine.command is returned for chaining.
The wrapper invokes it post-capture so claude-hud / the user's
custom status bar still renders for omnigent-launched sessions.
"""
fake_home = tmp_path
settings = fake_home / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"statusLine": {"type": "command", "command": "bun run hud"}}),
encoding="utf-8",
)
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", settings)
assert claude_native_bridge.read_user_status_line_command() == "bun run hud"
def test_read_user_status_line_command_returns_none_when_unset(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No global statusLine → chain is omitted; wrapper prints nothing extra."""
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"model": "opus"}), encoding="utf-8")
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", settings)
assert claude_native_bridge.read_user_status_line_command() is None
@pytest.mark.parametrize("effort", sorted(CLAUDE_EFFORTS))
def test_read_user_effort_level_returns_configured_level(
effort: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A recognized ``effortLevel`` is returned, to stamp on the session row."""
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"effortLevel": effort}), encoding="utf-8")
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", settings)
assert claude_native_bridge.read_user_effort_level() == effort
def test_read_user_effort_level_returns_none_when_unset(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No ``effortLevel`` → None, so creation omits reasoning_effort entirely."""
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"model": "opus"}), encoding="utf-8")
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", settings)
assert claude_native_bridge.read_user_effort_level() is None
def test_read_user_effort_level_rejects_unrecognized_value(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unrecognized value is treated as unset (fail-soft, so launch never 400s)."""
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"effortLevel": "ultra"}), encoding="utf-8")
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", settings)
assert claude_native_bridge.read_user_effort_level() is None
def test_read_user_effort_level_returns_none_when_settings_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Missing settings.json → None; absence of config must not raise."""
missing = tmp_path / "does-not-exist" / "settings.json"
monkeypatch.setattr(claude_native_bridge, "_USER_CLAUDE_SETTINGS_PATH", missing)
assert claude_native_bridge.read_user_effort_level() is None
# ---------------------------------------------------------------------------
# launch_model storage and retrieval
# ---------------------------------------------------------------------------
def test_prepare_bridge_dir_stores_launch_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``launch_model`` is persisted in bridge.json when provided."""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
launch_model="databricks-claude-opus-4-7",
)
config = json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))
assert config["launch_model"] == "databricks-claude-opus-4-7"
def test_prepare_bridge_dir_omits_launch_model_when_none(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``launch_model`` key is absent from bridge.json when not provided."""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
)
config = json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))
assert "launch_model" not in config
def test_read_launch_model_returns_stored_value(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``read_launch_model`` round-trips the value stored by ``prepare_bridge_dir``."""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
launch_model="databricks-claude-sonnet-4-6",
)
assert read_launch_model(bridge_dir) == "databricks-claude-sonnet-4-6"
def test_read_launch_model_returns_none_when_absent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``read_launch_model`` returns ``None`` when no launch model was stored."""
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
)
assert read_launch_model(bridge_dir) is None
def test_read_launch_model_returns_none_for_missing_bridge_dir(
tmp_path: Path,
) -> None:
"""``read_launch_model`` returns ``None`` for a nonexistent bridge dir."""
assert read_launch_model(tmp_path / "nonexistent") is None
def test_model_env_round_trips_the_launch_vocabulary(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The runner reads the alias pinning from here, not from its own env."""
from omnigent.claude_native_bridge import read_model_env
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
launch_model="databricks-claude-opus-4-8",
launch_env={
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
# Unrelated launch env must not be persisted.
"ANTHROPIC_BASE_URL": "https://example.invalid",
},
)
assert read_model_env(bridge_dir) == {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
}
def test_record_model_vocabulary_backfills_a_runner_prepared_bridge(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The runner path records the vocabulary AFTER preparing the bridge.
The runner prepares the bridge before it resolves the provider config,
so the pins and launch model land via a second write — and must read
back exactly as the CLI path's prepare-time record does.
"""
from omnigent.claude_native_bridge import (
read_launch_model,
read_model_env,
record_model_vocabulary,
)
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
assert read_model_env(bridge_dir) == {}
record_model_vocabulary(
bridge_dir,
launch_env={
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_BASE_URL": "https://example.invalid",
},
launch_model="databricks-claude-opus-4-8",
)
assert read_model_env(bridge_dir) == {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
}
assert read_launch_model(bridge_dir) == "databricks-claude-opus-4-8"
# A bare subscription launch records nothing and disturbs nothing.
record_model_vocabulary(bridge_dir, launch_env=None, launch_model=None)
assert read_model_env(bridge_dir) == {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
}
def test_model_env_is_empty_without_a_ucode_launch(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from omnigent.claude_native_bridge import read_model_env
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
assert read_model_env(bridge_dir) == {}
assert read_model_env(tmp_path / "nonexistent") == {}
# ── _hook_record_from_jsonl_record: task/todo event parsing ──────────────────
def _make_jsonl_record(
payload: dict[str, Any],
*,
line_number: int = 1,
) -> _JsonlRecord:
"""
Build a ``_JsonlRecord`` wrapping ``payload`` in the hook JSONL envelope.
:param payload: Hook payload dict (written under the ``payload`` key).
:param line_number: Synthetic line number for the record.
:returns: A ``_JsonlRecord`` with correct byte offsets and encoded text.
"""
text = json.dumps({"payload": payload}) + "\n"
return _JsonlRecord(
line_number=line_number,
byte_offset=0,
next_byte_offset=len(text.encode()),
text=text,
)
def test_hook_record_parses_todo_write_todos() -> None:
"""
``PostToolUse/TodoWrite`` → ``record.todos`` is the items list.
This fails if the ``tool_input.todos`` extraction path is broken or
the dict-only filter is removed (non-dict entries would leak through).
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "PostToolUse",
"tool_name": "TodoWrite",
"tool_input": {
"todos": [
{
"content": "Fix bug",
"status": "in_progress",
"activeForm": "Fixing bug",
},
"not-a-dict", # must be filtered out
],
},
}
)
)
assert record.event_name == "PostToolUse"
# Only the dict entry survives; the string is dropped.
assert record.todos == [
{"content": "Fix bug", "status": "in_progress", "activeForm": "Fixing bug"}
]
assert record.task_id is None
assert record.task_status is None
def test_hook_record_parses_task_update() -> None:
"""
``PostToolUse/TaskUpdate`` → ``record.task_id`` and ``record.task_status``.
This fails if the ``taskId``/``status`` extraction from
``tool_input`` is broken or if the field names change.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "PostToolUse",
"tool_name": "TaskUpdate",
"tool_input": {"taskId": "42", "status": "in_progress"},
}
)
)
assert record.event_name == "PostToolUse"
assert record.task_id == "42"
assert record.task_status == "in_progress"
assert record.todos is None
def test_hook_record_parses_task_created() -> None:
"""
``TaskCreated`` → ``task_id``, ``task_subject``, ``task_status == "pending"``.
This fails if the field names change or the hardcoded "pending"
assignment is removed.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "TaskCreated",
"task_id": "7",
"task_subject": "Write integration tests",
}
)
)
assert record.event_name == "TaskCreated"
assert record.task_id == "7"
assert record.task_subject == "Write integration tests"
# TaskCreated always sets status to "pending" regardless of payload.
assert record.task_status == "pending"
assert record.todos is None
def test_hook_record_parses_task_completed() -> None:
"""
``TaskCompleted`` → ``task_id``, ``task_status == "completed"``.
This fails if the hardcoded "completed" assignment is removed or if
task_id extraction from the payload breaks.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "TaskCompleted",
"task_id": "7",
}
)
)
assert record.event_name == "TaskCompleted"
assert record.task_id == "7"
assert record.task_status == "completed"
assert record.task_subject is None
def test_hook_record_task_created_with_int_task_id_gives_none() -> None:
"""
Malformed ``TaskCreated`` with a numeric ``task_id`` → ``task_id`` is ``None``.
Ensures the ``isinstance(raw_task_id, str)`` guard prevents integer ids
from leaking into the accumulation maps (which key by str).
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "TaskCreated",
"task_id": 99, # int, not str
"task_subject": "Some task",
}
)
)
assert record.task_id is None
assert record.task_subject == "Some task"
# task_status is still set even when task_id is absent.
assert record.task_status == "pending"
def test_hook_record_task_update_with_missing_task_id_gives_none() -> None:
"""
``PostToolUse/TaskUpdate`` with no ``taskId`` → ``task_id`` is ``None``.
Ensures the forwarder safely ignores updates for tasks whose id
cannot be extracted (they would silently create orphaned map entries
if the None guard were absent).
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "PostToolUse",
"tool_name": "TaskUpdate",
"tool_input": {"status": "completed"}, # taskId missing
}
)
)
assert record.task_id is None
assert record.task_status == "completed"
def test_hook_record_todo_write_with_non_list_todos_gives_none() -> None:
"""
``PostToolUse/TodoWrite`` with ``tool_input.todos`` as a non-list → ``todos`` is ``None``.
Protects against forwarder crashing when a malformed hook payload
arrives with an unexpected ``todos`` shape.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "PostToolUse",
"tool_name": "TodoWrite",
"tool_input": {"todos": "not-a-list"},
}
)
)
assert record.todos is None
# ── stop_hook_seen_since: subagent filtering ─────────────────────────
def test_stop_hook_seen_since_ignores_subagent_stop(
tmp_path: Path,
) -> None:
"""
``stop_hook_seen_since`` must skip subagent ``Stop`` events.
When a Claude Code subagent (spawned via the Agent tool) finishes,
its ``Stop`` hook lands in the same ``hooks.jsonl`` as the parent's.
The subagent's ``transcript_path`` contains a ``subagents/``
component — ``stop_hook_seen_since`` uses this to distinguish it
from a parent stop. Without this filter, the tmux message injection
path prematurely considers the parent turn complete.
"""
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",
"transcript_path": str(transcript_path),
},
)
cursor = 1
# Subagent Stop — must not be detected.
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "subagent",
"transcript_path": str(subagent_transcript),
},
)
assert not stop_hook_seen_since(bridge_dir, cursor)
# Parent Stop — must be detected.
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "parent",
"transcript_path": str(transcript_path),
},
)
assert stop_hook_seen_since(bridge_dir, cursor)
def test_stop_hook_seen_since_detects_stop_without_transcript_path(
tmp_path: Path,
) -> None:
"""
``stop_hook_seen_since`` treats ``Stop`` without a transcript path as a parent event.
If a hook payload omits ``transcript_path`` (unusual but possible),
the conservative default is to treat it as a parent event so the
turn-complete signal is not lost.
"""
bridge_dir = tmp_path / "bridge"
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "parent",
},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "Stop",
"session_id": "parent",
},
)
assert stop_hook_seen_since(bridge_dir, 1)
# ── ensure_claude_workspace_trusted ────────────────────
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> Path:
"""
Point ``Path.home()`` (and thus ``~/.claude.json``) at a temp dir.
``Path.home()`` resolves ``~`` via ``$HOME`` on POSIX, so setting the
env var redirects the helper's reads/writes to *home* without
patching any production internals.
:param monkeypatch: Pytest monkeypatch fixture.
:param home: Temp directory to use as the fake home, e.g.
``tmp_path / "home"``.
:returns: The ``.claude.json`` path inside *home*.
"""
home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
assert Path.home() == home # guards against env-resolution surprises
return home / ".claude.json"
def test_ensure_trusted_creates_config_when_missing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A fresh host (no ``~/.claude.json``) gets both first-run gates set.
This is the core workspace-trust case: an ``omnigent host`` machine that
has never run Claude interactively. Both the global onboarding gate
and the per-workspace trust gate must be pre-accepted so Claude does
not block on its TUI prompts.
"""
config_path = _redirect_home(monkeypatch, tmp_path / "home")
workspace = tmp_path / "worktrees" / "feature-x"
workspace.mkdir(parents=True)
ensure_claude_workspace_trusted(workspace)
data = json.loads(config_path.read_text())
# Global onboarding gate — without this Claude shows the theme/login
# flow on a never-onboarded machine.
assert data["hasCompletedOnboarding"] is True
# Per-directory trust gate, keyed by the RESOLVED absolute path —
# without this Claude shows "Do you trust the files in this folder?".
assert data["projects"][str(workspace.resolve())]["hasTrustDialogAccepted"] is True
def test_ensure_trusted_preserves_existing_state(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Seeding adds only the two gates; all other config is preserved.
The file holds the user's OAuth account, MCP config, and per-project
history. Clobbering any of it would be a regression, so this asserts
an unrelated top-level key and a sibling project entry survive
untouched while the new workspace's trust gate is added.
"""
config_path = _redirect_home(monkeypatch, tmp_path / "home")
other_workspace = tmp_path / "repo"
other_workspace.mkdir()
existing = {
"oauthAccount": {"emailAddress": "user@example.com"},
"hasCompletedOnboarding": True,
"projects": {
str(other_workspace.resolve()): {
"hasTrustDialogAccepted": True,
"lastSessionId": "sess_existing",
},
},
}
config_path.write_text(json.dumps(existing))
workspace = tmp_path / "worktrees" / "feature-y"
workspace.mkdir(parents=True)
ensure_claude_workspace_trusted(workspace)
data = json.loads(config_path.read_text())
# Unrelated top-level state survives — proves we merge, not overwrite.
assert data["oauthAccount"] == {"emailAddress": "user@example.com"}
# The pre-existing sibling project is untouched, including its own
# non-trust keys (a naive ``projects = {key: {...}}`` would drop it).
assert data["projects"][str(other_workspace.resolve())] == {
"hasTrustDialogAccepted": True,
"lastSessionId": "sess_existing",
}
# The new workspace's trust gate was added alongside it.
assert data["projects"][str(workspace.resolve())]["hasTrustDialogAccepted"] is True
def test_ensure_trusted_idempotent_does_not_rewrite(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When both gates are already set, the file is left byte-for-byte alone.
The seed file is written in compact form; the helper's writer uses
two-space indentation. So if the ``if not changed: return`` short-
circuit were removed, the file would be reformatted and the bytes
would differ — this test would then fail, proving it guards the
no-op path (not merely that the values end up correct).
"""
config_path = _redirect_home(monkeypatch, tmp_path / "home")
workspace = tmp_path / "worktrees" / "feature-z"
workspace.mkdir(parents=True)
already = {
"hasCompletedOnboarding": True,
"projects": {str(workspace.resolve()): {"hasTrustDialogAccepted": True}},
}
# Compact, no indentation — distinct from the helper's indent=2 output.
config_path.write_text(json.dumps(already, separators=(",", ":")))
before = config_path.read_bytes()
ensure_claude_workspace_trusted(workspace)
# Byte-identical → the helper detected no change and never wrote.
assert config_path.read_bytes() == before
@pytest.mark.parametrize(
"raw,expected_exc",
[
# Invalid JSON — a half-written or corrupt file. Must fail loud,
# never be silently replaced (losing the user's real config).
("{not valid json", json.JSONDecodeError),
# Valid JSON but not an object (e.g. a stray list). Claude never
# writes this shape, so refuse rather than coerce it.
("[1, 2, 3]", ValueError),
],
)
def test_ensure_trusted_refuses_malformed_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
raw: str,
expected_exc: type[Exception],
) -> None:
"""
A malformed ``~/.claude.json`` raises and is left untouched.
Fail-loud per project conventions: silently overwriting would
destroy whatever the user actually had on disk.
"""
config_path = _redirect_home(monkeypatch, tmp_path / "home")
config_path.write_text(raw)
workspace = tmp_path / "ws"
workspace.mkdir()
with pytest.raises(expected_exc):
ensure_claude_workspace_trusted(workspace)
# The original (malformed) bytes are preserved — no clobber occurred.
assert config_path.read_text() == raw
def test_display_cost_approval_popup_builds_detached_tmux_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Fires a detached ``display-popup`` at the advertised pane running the
cost-popup module with all resolve inputs.
Proves the modal targets the right tmux socket + pane + attached
client (``-c``), launches :mod:`omnigent.native_cost_popup`, and
forwards the session/elicitation/message plus THIS bridge's
``permission_hook.json`` (where the popup reads the Omnigent url/token). A
failure means native approval would render at the wrong pane/client,
run the wrong program, or omit an input the resolve POST needs — i.e.
it silently wouldn't work.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
(bridge_dir / "tmux.json").write_text(
json.dumps({"socket_path": "/tmp/x.sock", "tmux_target": "claude:0.0"}),
encoding="utf-8",
)
# One client attached — display-popup must target it via ``-c`` since
# the runner invoking the helper is not itself a tmux client.
monkeypatch.setattr(native_cost_popup, "_list_tmux_clients", lambda _s, _t: ["/dev/pts/9"])
captured: dict[str, Any] = {}
class _FakePopen:
"""Records the argv/kwargs; deliberately has no ``wait()`` so the
test fails loud if the helper ever blocks on the popup."""
def __init__(self, args: list[str], **kwargs: Any) -> None:
captured["args"] = args
captured["kwargs"] = kwargs
monkeypatch.setattr(subprocess, "Popen", _FakePopen)
display_cost_approval_popup(
bridge_dir,
session_id="conv_abc123",
elicitation_id="elicit_deadbeef",
message="Cost $0.12 crossed the $0.10 checkpoint. Continue?",
timeout_s=1.0,
)
args = captured["args"]
# Targets the advertised socket + pane via display-popup -E (so the
# popup closes when the script exits).
assert args[:5] == ["tmux", "-S", "/tmp/x.sock", "display-popup", "-E"]
# Must target the attached client explicitly; without -c the runner
# (not a tmux client) gets "no current client" and nothing renders.
assert args[args.index("-c") + 1] == "/dev/pts/9"
# The pane comes from tmux.json; a wrong target would render nowhere.
assert args[args.index("-t") + 1] == "claude:0.0"
# Inner command runs the popup module with every resolve input.
inner = shlex.split(args[-1])
assert "omnigent.native_cost_popup" in inner
assert "conv_abc123" in inner # --session-id value
assert "elicit_deadbeef" in inner # --elicitation-id value
assert "Cost $0.12 crossed the $0.10 checkpoint. Continue?" in inner # --message
# AP-routing config must point at THIS bridge's permission_hook.json so
# the popup reads the matching ap_server_url + auth headers.
cfg = inner[inner.index("--config-file") + 1]
assert cfg.endswith("permission_hook.json")
assert str(bridge_dir) in cfg
# Detached, non-interactive stdio — the runner handler must not inherit
# a tty or block; DEVNULL on all three proves fire-and-forget.
assert captured["kwargs"]["stdin"] == subprocess.DEVNULL
assert captured["kwargs"]["stdout"] == subprocess.DEVNULL
assert captured["kwargs"]["stderr"] == subprocess.DEVNULL
def test_display_cost_approval_popup_honors_config_file_override(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``config_file`` override is forwarded instead of permission_hook.json.
The runner passes a freshly-minted AP-routing snapshot here so a cost gate
firing late in a session reads a live bearer, not the stale launch token in
permission_hook.json. Without honoring the override the verdict POST would
401 and silently lose the approval.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
(bridge_dir / "tmux.json").write_text(
json.dumps({"socket_path": "/tmp/x.sock", "tmux_target": "claude:0.0"}),
encoding="utf-8",
)
monkeypatch.setattr(native_cost_popup, "_list_tmux_clients", lambda _s, _t: ["/dev/pts/9"])
captured: dict[str, Any] = {}
monkeypatch.setattr(
subprocess, "Popen", lambda args, **kwargs: captured.setdefault("args", args)
)
fresh = bridge_dir / "cost_popup.json"
display_cost_approval_popup(
bridge_dir,
session_id="conv_abc123",
elicitation_id="elicit_deadbeef",
message="continue?",
timeout_s=1.0,
config_file=fresh,
)
inner = shlex.split(captured["args"][-1])
cfg = inner[inner.index("--config-file") + 1]
assert cfg == str(fresh)
assert not cfg.endswith("permission_hook.json")
def test_display_cost_approval_popup_skips_when_no_client_attached(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With the pane advertised but NO client attached, no popup is fired.
A ``display-popup`` needs an attached client to render on; with none
(e.g. the web Terminal tab closed) tmux would error "no current
client". The helper must skip cleanly — NOT spawn a doomed ``tmux`` —
leaving the web ApprovalCard as the answer surface. A regression here
(spawning anyway) means noisy failed popups and, worse, masks that the
real surface is the web card.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
(bridge_dir / "tmux.json").write_text(
json.dumps({"socket_path": "/tmp/x.sock", "tmux_target": "claude:0.0"}),
encoding="utf-8",
)
# No client attached.
monkeypatch.setattr(native_cost_popup, "_list_tmux_clients", lambda _s, _t: [])
spawned: list[list[str]] = []
class _FakePopen:
def __init__(self, args: list[str], **kwargs: Any) -> None:
spawned.append(args)
monkeypatch.setattr(subprocess, "Popen", _FakePopen)
display_cost_approval_popup(
bridge_dir,
session_id="conv_abc123",
elicitation_id="elicit_x",
message="msg",
timeout_s=1.0,
)
# No popup spawned when there is no client to render it on.
assert spawned == []
def test_display_cost_approval_popup_raises_when_pane_not_advertised(
tmp_path: Path,
) -> None:
"""
With no ``tmux.json`` (pane not attached), the helper raises.
The runner handler catches this as a best-effort miss so the web
ApprovalCard stays the answer surface; it must NOT fire a popup at a
nonexistent pane. A pass-through (no raise) would mean the runner
couldn't tell the pane was absent.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
with pytest.raises(RuntimeError):
display_cost_approval_popup(
bridge_dir,
session_id="conv_abc123",
elicitation_id="elicit_x",
message="msg",
timeout_s=0.05,
)
def test_claude_prompt_rendered_sees_prompt_above_default_footer() -> None:
"""
The readiness scan reaches the prompt glyph above Claude's footer.
Claude Code 2.1.x renders a footer below the input box (the box's
closing rule line, the cwd/status line, the model+effort line, and
the permission-mode hint), so the live ```` row is the 5th
non-empty line from the bottom — NOT the last. The prior 4-line scan
window never reached it and the web-UI readiness gate timed out with
"did not become ready" even though the box was mounted. A failure
here means the scan window regressed below 5 and the first web
message would be dropped.
"""
pane = "\n".join(
[
"────────────────────────────────────────", # input box top rule
" ", # the live prompt row (5th non-empty line from bottom)
"────────────────────────────────────────", # box closing rule
" alice: /home/alice/proj Remote Control failed", # status line
" Opus 4.8 (1M context) | effort:high", # model + effort line
" ⏵⏵ don't ask on (shift+tab to cycle) · ← for agents", # hint line
]
)
# ```` is 5 non-empty lines above the bottom (rule + status + model
# + hint sit below it), so only a scan window of >= 5 reaches it.
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_sees_prompt_above_running_turn_footer() -> None:
"""
The readiness scan reaches the prompt above a tall running-turn footer.
When a web-UI message is injected while Claude is mid-turn, the footer
grows extra status rows below the input box — a running-subagent line
(``○ Explore …``) on top of the usual box rule, model, auto-mode, and
branch rows. That pushes the live ```` row to the 6th non-empty line
from the bottom, one past the old 5-line window, so the readiness gate
timed out and the web UI rendered a spurious "did not become ready"
runtime-error card even though the terminal was healthy.
"""
pane = "\n".join(
[
"────────────────────────────────────────", # input box top rule
" ", # the live prompt row (6th non-empty line from bottom)
"────────────────────────────────────────", # box closing rule
" Opus 4.8 (1M context) | thinking medium", # model + effort line
" ⏵⏵ auto mode on (shift+tab to cycle)", # permission-mode hint
" main", # branch label
" ○ Explore Find session sidebar state… 1m 4s", # subagent status
]
)
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_sees_prompt_above_subagent_fanout_footer() -> None:
"""
The readiness scan reaches the prompt above an unbounded subagent footer.
A subagent fan-out renders one ``○ Explore …`` row per concurrent
subagent, so the running-turn footer height is unbounded. Here five
subagents plus the model/auto-mode/branch rows push the live ```` row
to the 12th non-empty line from the bottom — far past any fixed scan
window. The box rule below ```` (the input box's closing frame) is
what admits it, so the scan must reach the glyph at this depth and the
web UI must NOT render a spurious "did not become ready" card.
"""
pane = "\n".join(
[
"────────────────────────────────────────", # input box top rule
" Press up to edit queued messages", # the live prompt row
"────────────────────────────────────────", # box closing rule
" Opus 4.8 (1M context) | thinking medium | 398.1k/1M (39%)",
" ⏵⏵ auto mode on (shift+tab to cycle)",
" main",
" ○ Explore Angle A: line-by-line diff scan 1m 35s",
" ○ Explore Angle C: cross-file tracer 56s",
" ○ Explore Angle D: reuse 46s",
" ○ Explore Angle F: efficiency 31s",
" ○ Explore Angle G: altitude 21s",
]
)
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_ignores_unframed_glyph_deep_in_tail() -> None:
"""
A glyph in the wider window without a box rule below is not trusted.
The framed window that lets the scan reach a prompt under a tall
running-turn footer must not resurrect the scrollback false positive:
a ```` echoed into prior output sits in the wider window too, but
without the input box's closing ``────`` rule beneath it. Only plain
output follows here, so the gate must still report "not ready".
"""
pane = "\n".join(
[
" old prompt echo", # 6th non-empty line from bottom, no rule below
"output line 1",
"output line 2",
"output line 3",
"output line 4",
"output line 5",
]
)
assert _claude_prompt_rendered(pane) is False
def test_claude_prompt_rendered_ignores_custom_api_key_menu() -> None:
"""The custom-API-key menu's selected row is not the chat input.
Claude Code's startup "Detected a custom API key" confirmation renders
its highlighted choice with the same ```` glyph the chat composer uses
(`` 2. No (recommended)``). Treating it as ready pastes the first web
message into the menu, so no turn starts — the tmux delivery false
positive this guards against.
"""
pane = "\n".join(
[
"Detected a custom API key in your environment",
"Do you want to use this API key?",
" 1. Yes",
" 2. No (recommended)", # selected menu row, NOT the chat input
]
)
assert _claude_prompt_rendered(pane) is False
def test_claude_prompt_rendered_sees_chat_input_below_menu_glyph() -> None:
"""A real chat prompt still reads ready even past a menu-shaped echo.
A numbered ```` choice echoed into scrollback must not suppress the
live input box below it: the chat ```` row (no numbered choice after
the glyph) is the one that marks readiness.
"""
pane = "\n".join(
[
" 2. No (recommended)", # scrollback echo of a prior menu row
"output",
"────────────────────────────────────────", # input box top rule
" ", # the live chat prompt
"────────────────────────────────────────", # box closing rule
" Opus 4.8 (1M context) | effort:high",
]
)
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_sees_numbered_draft_in_framed_input() -> None:
"""A numbered composer draft is distinguished from an unframed menu row."""
pane = "\n".join(
[
"────────────────────────────────────────",
" 2. buy milk",
"────────────────────────────────────────",
" Opus 4.8 (1M context) | effort:high",
]
)
assert _claude_prompt_rendered(pane) is True
def _write_deltas_lines(bridge_dir: Path, lines: list[str]) -> None:
"""
Append raw JSONL lines to the bridge deltas file.
:param bridge_dir: Bridge directory.
:param lines: Already-serialized JSON strings (no trailing newline);
each is written as its own newline-terminated record.
:returns: None.
"""
bridge_dir.mkdir(parents=True, exist_ok=True)
with (bridge_dir / "message_deltas.jsonl").open("a", encoding="utf-8") as handle:
for line in lines:
handle.write(line + "\n")
def test_read_message_deltas_parses_and_advances_offset(tmp_path: Path) -> None:
"""
Complete delta records parse in order and the offset reaches EOF.
Fails if the reader drops fields or stops short of the last complete
record — either would lose streamed chunks the forwarder must POST.
"""
bridge_dir = tmp_path / "bridge"
_write_deltas_lines(
bridge_dir,
[
json.dumps({"message_id": "m1", "index": 0, "final": False, "delta": "Hello "}),
json.dumps({"message_id": "m1", "index": 1, "final": True, "delta": "world"}),
],
)
result = read_message_deltas_from_offset(bridge_dir, 0)
assert [(d.message_id, d.index, d.final, d.delta) for d in result.deltas] == [
("m1", 0, False, "Hello "),
("m1", 1, True, "world"),
]
# Offset reached EOF, so a follow-up read sees nothing new.
assert result.byte_offset == os.path.getsize(bridge_dir / "message_deltas.jsonl")
assert read_message_deltas_from_offset(bridge_dir, result.byte_offset).deltas == []
def test_read_message_deltas_resumes_from_offset(tmp_path: Path) -> None:
"""
A second read from the prior offset returns only newly appended records.
Fails if the byte cursor is ignored (re-reading old chunks → the
forwarder re-POSTs and the UI double-renders) or over-advances
(skipping a new chunk → a gap in the live text).
"""
bridge_dir = tmp_path / "bridge"
_write_deltas_lines(
bridge_dir, [json.dumps({"message_id": "m1", "index": 0, "final": True, "delta": "one"})]
)
first = read_message_deltas_from_offset(bridge_dir, 0)
_write_deltas_lines(
bridge_dir, [json.dumps({"message_id": "m2", "index": 0, "final": True, "delta": "two"})]
)
second = read_message_deltas_from_offset(bridge_dir, first.byte_offset)
assert [(d.message_id, d.delta) for d in second.deltas] == [("m2", "two")]
def test_read_message_deltas_skips_partial_trailing_line(tmp_path: Path) -> None:
"""
A half-written final line is not consumed until its newline lands.
The hook writes each line with O_APPEND, but the reader can observe a
record mid-write; it must leave the offset before the partial line so
the next poll retries it. Fails if a torn line is parsed (data loss)
or advances the offset past unterminated bytes (permanent loss).
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
complete = json.dumps({"message_id": "m1", "index": 0, "final": False, "delta": "done"}) + "\n"
partial = json.dumps({"message_id": "m1", "index": 1, "final": True, "delta": "half"}) # no \n
(bridge_dir / "message_deltas.jsonl").write_text(complete + partial, encoding="utf-8")
result = read_message_deltas_from_offset(bridge_dir, 0)
# Only the newline-terminated record is returned...
assert [d.delta for d in result.deltas] == ["done"]
# ...and the offset sits at the start of the partial line, so once
# the hook finishes that line a re-read picks it up.
assert result.byte_offset == len(complete.encode("utf-8"))
def test_read_message_deltas_skips_malformed_records(tmp_path: Path) -> None:
"""
Lines that aren't well-formed deltas are dropped, not fatal.
A line missing ``message_id``, with a non-string ``delta``, with a
boolean ``index`` (``bool`` is an ``int`` subclass), or that isn't
JSON must be skipped while still advancing past it — one bad line
must never wedge the live tail. Fails if a malformed record is
surfaced (→ a malformed SSE event) or halts the read.
"""
bridge_dir = tmp_path / "bridge"
_write_deltas_lines(
bridge_dir,
[
"{not json",
json.dumps({"index": 0, "delta": "no id"}),
json.dumps({"message_id": "m1", "index": 0, "delta": 123}),
json.dumps({"message_id": "m1", "index": True, "delta": "bool index"}),
json.dumps({"message_id": "m1", "index": 2, "final": True, "delta": "good"}),
],
)
result = read_message_deltas_from_offset(bridge_dir, 0)
# Only the one well-formed record survives; the four bad lines are
# skipped but the offset still reached EOF (they were consumed).
assert [(d.message_id, d.index, d.delta) for d in result.deltas] == [("m1", 2, "good")]
assert result.byte_offset == os.path.getsize(bridge_dir / "message_deltas.jsonl")
def test_read_message_deltas_missing_file_is_empty(tmp_path: Path) -> None:
"""
A read before the hook has written anything returns no deltas.
Fails if a missing file raises instead of being treated as "no
chunks yet" — the forwarder polls this every tick before streaming
has started.
"""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
result = read_message_deltas_from_offset(bridge_dir, 0)
assert result.deltas == []
assert result.byte_offset == 0
# ── compute_transcript_cumulative_cost ────────────────────────────────
def _write_transcript_jsonl(path: Path, entries: list[dict[str, Any]]) -> None:
"""
Write transcript records as newline-terminated JSONL.
:param path: Destination JSONL path.
:param entries: Decoded transcript records to serialize, one per
line, e.g. an assistant message with a ``usage`` block.
:returns: None.
"""
path.write_text(
"".join(json.dumps(entry) + "\n" for entry in entries),
encoding="utf-8",
)
def _assistant_entry(
*,
model: str,
input_tokens: int,
output_tokens: int,
is_sidechain: bool = False,
request_id: str | None = None,
) -> dict[str, Any]:
"""
Build one assistant transcript record with a usage block.
:param model: ``message.model`` to stamp, e.g. ``"test-model"``.
:param input_tokens: Non-cached input tokens for the usage block.
:param output_tokens: Output tokens for the usage block.
:param is_sidechain: When ``True`` mark the record ``isSidechain``
(how a sub-agent message is inlined into a parent transcript).
:param request_id: Top-level ``requestId`` to stamp, e.g.
``"req_A"``. ``None`` omits it (records without a ``requestId``
are each billed once, never collapsed together).
:returns: A decoded transcript record dict.
"""
entry: dict[str, Any] = {
"message": {
"role": "assistant",
"model": model,
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
}
}
if is_sidechain:
entry["isSidechain"] = True
if request_id is not None:
entry["requestId"] = request_id
return entry
def test_compute_transcript_cumulative_cost_dedupes_by_request_id(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
Records sharing a ``requestId`` are billed once, not once per record.
Claude writes the same API response as multiple transcript records (a
streamed partial plus the final record), each carrying that response's
full ``usage`` under one ``requestId``. Summing every record
double-bills (observed ~2x inflation, which leaked into the parent
badge and the cost-budget gate). The cost must dedupe by ``requestId``.
"""
from omnigent.llms.context_window import ModelPricing
pricing = ModelPricing(input_per_token=10.0, output_per_token=20.0)
monkeypatch.setattr("omnigent.llms.context_window.fetch_model_pricing", lambda model: pricing)
claude_native_bridge._TRANSCRIPT_PRICING_CACHE.clear()
path = tmp_path / "transcript.jsonl"
_write_transcript_jsonl(
path,
[
# Two records, SAME requestId = one billed response (2*10 + 3*20 = 80).
_assistant_entry(model="m", input_tokens=2, output_tokens=3, request_id="req_A"),
_assistant_entry(model="m", input_tokens=2, output_tokens=3, request_id="req_A"),
# Distinct requestId = a second billed response (1*10 + 1*20 = 30).
_assistant_entry(model="m", input_tokens=1, output_tokens=1, request_id="req_B"),
],
)
cost = claude_native_bridge.compute_transcript_cumulative_cost(path, include_sidechains=True)
# 80 + 30 = 110. Without dedup the duplicate req_A record adds another
# 80 → 190, the ~2x over-report this dedup fixes.
assert cost == pytest.approx(110.0)
def test_compute_transcript_cumulative_cost_sums_priced_messages(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
Cost is the sum over every priced assistant message in the transcript.
Asserts the exact USD total (real :func:`compute_llm_cost` math), so
a regression in per-message summation or token pricing fails loudly —
not just "some positive number".
"""
from omnigent.llms.context_window import ModelPricing
pricing = ModelPricing(input_per_token=10.0, output_per_token=20.0)
monkeypatch.setattr("omnigent.llms.context_window.fetch_model_pricing", lambda model: pricing)
claude_native_bridge._TRANSCRIPT_PRICING_CACHE.clear()
path = tmp_path / "transcript.jsonl"
_write_transcript_jsonl(
path,
[
_assistant_entry(model="m", input_tokens=2, output_tokens=3), # 2*10 + 3*20 = 80
{"message": {"role": "user", "content": "hi"}}, # no usage → skipped
_assistant_entry(model="m", input_tokens=1, output_tokens=1), # 1*10 + 1*20 = 30
],
)
cost = claude_native_bridge.compute_transcript_cumulative_cost(path, include_sidechains=True)
assert cost == pytest.approx(110.0)
def test_compute_transcript_cumulative_cost_excludes_parent_sidechains(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
``include_sidechains=False`` skips inlined sub-agent records.
The parent transcript inlines a sub-agent's messages as
``isSidechain: true``; counting them there AND via the sub-agent's
own transcript would double-bill, so the parent path must drop them.
The sub-agent path (``include_sidechains=True``) counts everything.
"""
from omnigent.llms.context_window import ModelPricing
pricing = ModelPricing(input_per_token=10.0, output_per_token=0.0)
monkeypatch.setattr("omnigent.llms.context_window.fetch_model_pricing", lambda model: pricing)
claude_native_bridge._TRANSCRIPT_PRICING_CACHE.clear()
path = tmp_path / "parent.jsonl"
_write_transcript_jsonl(
path,
[
_assistant_entry(model="m", input_tokens=5, output_tokens=0), # own → 50
_assistant_entry( # inlined sub-agent → 1000
model="m", input_tokens=100, output_tokens=0, is_sidechain=True
),
],
)
assert claude_native_bridge.compute_transcript_cumulative_cost(
path, include_sidechains=False
) == pytest.approx(50.0)
assert claude_native_bridge.compute_transcript_cumulative_cost(
path, include_sidechains=True
) == pytest.approx(1050.0)
def test_compute_transcript_cumulative_cost_none_when_nothing_priceable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""
Returns ``None`` (not ``0.0``) when no message can be priced.
Covers a missing file, a transcript with no assistant usage, and a
model with no available pricing — each must yield ``None`` so the
forwarder treats it as "no estimate" rather than "$0 spent".
"""
from omnigent.llms.context_window import ModelPricing
claude_native_bridge._TRANSCRIPT_PRICING_CACHE.clear()
# Missing file.
assert (
claude_native_bridge.compute_transcript_cumulative_cost(
tmp_path / "missing.jsonl", include_sidechains=True
)
is None
)
# File with no assistant usage.
pricing = ModelPricing(input_per_token=10.0, output_per_token=20.0)
monkeypatch.setattr("omnigent.llms.context_window.fetch_model_pricing", lambda model: pricing)
no_usage = tmp_path / "no_usage.jsonl"
_write_transcript_jsonl(no_usage, [{"message": {"role": "user", "content": "hi"}}])
assert (
claude_native_bridge.compute_transcript_cumulative_cost(no_usage, include_sidechains=True)
is None
)
# Pricing unavailable for the model.
claude_native_bridge._TRANSCRIPT_PRICING_CACHE.clear()
monkeypatch.setattr("omnigent.llms.context_window.fetch_model_pricing", lambda model: None)
priced = tmp_path / "priced.jsonl"
_write_transcript_jsonl(priced, [_assistant_entry(model="m", input_tokens=5, output_tokens=5)])
assert (
claude_native_bridge.compute_transcript_cumulative_cost(priced, include_sidechains=True)
is None
)
def test_format_terminal_failure_tail_returns_empty_for_blank_pane() -> None:
"""
A pane with no visible text yields no tail block.
:returns: None.
"""
assert claude_native_bridge._format_terminal_failure_tail(" \n\n ") == ""
def test_format_terminal_failure_tail_includes_recent_error_lines() -> None:
"""
The tail block carries the pane's trailing non-blank lines, so a
startup crash surfaces in the readiness-timeout error.
:returns: None.
"""
pane = "ERROR JSON Parse error: Unrecognized token '<'\n at <parse> (:0)\n"
tail = claude_native_bridge._format_terminal_failure_tail(pane)
assert tail.startswith(" Last terminal output:\n")
assert "JSON Parse error: Unrecognized token '<'" in tail
def test_format_terminal_failure_tail_caps_length(monkeypatch: pytest.MonkeyPatch) -> None:
"""
A very long pane is truncated to the configured character cap.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TERMINAL_FAILURE_TAIL_CHARS", 50)
pane = "\n".join(f"line {i}" for i in range(100))
tail = claude_native_bridge._format_terminal_failure_tail(pane)
body = tail.split("\n", 1)[1]
assert body.startswith("…")
# Leading ellipsis marker plus at most the configured character cap.
assert len(body) <= 51
def test_wait_for_claude_prompt_ready_surfaces_terminal_output_on_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
On a readiness timeout, the RuntimeError carries Claude Code's own
terminal output (e.g. a startup ``JSON Parse error``) so the cause
surfaces in the web UI error banner, not only in the terminal.
Without the tail, the user sees a generic "terminal did not become
ready" timeout in the UI while the actual crash sits unread in the
terminal pane.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
crash_pane = (
"ERROR JSON Parse error: Unrecognized token '<'\n"
" at <parse> (:0)\n"
" at parse (unknown)\n"
)
monkeypatch.setattr(
"omnigent.claude_native_bridge._capture_pane",
lambda socket_path, tmux_target: crash_pane,
)
with pytest.raises(RuntimeError) as excinfo:
claude_native_bridge._wait_for_claude_prompt_ready(
"/tmp/example/tmux.sock",
"claude:0.0",
timeout_s=0.0,
)
message = str(excinfo.value)
assert "did not become ready" in message
assert "Last terminal output:" in message
assert "JSON Parse error: Unrecognized token '<'" in message
def test_wait_for_claude_prompt_ready_reports_empty_capture_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A timeout where every capture came back empty says so in the error.
An empty ``capture-pane`` (a torn read under a busy mid-turn repaint,
not a boot crash) leaves no tail to attach. Without the poll/empty
counts the error is indistinguishable from "Claude never rendered a
prompt", which is what sent triage down the wrong path. The counts make
the two failure modes tell themselves apart.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
monkeypatch.setattr(
"omnigent.claude_native_bridge._capture_pane",
lambda socket_path, tmux_target: "",
)
with pytest.raises(RuntimeError) as excinfo:
claude_native_bridge._wait_for_claude_prompt_ready(
"/tmp/example/tmux.sock",
"claude:0.0",
timeout_s=0.0,
)
message = str(excinfo.value)
assert "did not become ready" in message
# One poll happened (do-while), and it was empty.
assert "1 polls, 1 empty captures" in message
# No pane text to surface when every capture was empty.
assert "Last terminal output:" not in message
def test_wait_for_claude_prompt_ready_tail_is_observed_not_recaptured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The attached tail is a capture the loop saw, not a post-timeout one.
The bug this guards: attaching a fresh capture taken *after* the
deadline can show a healthier frame (e.g. the input box repainting as
the turn settles) than any decision the loop actually made, so the
error misrepresents why the gate failed. The tail must come from a
capture observed while the loop was still deciding. A box-less capture
followed by a box-present one that arrives only after the deadline
proves the point: the box-present frame must NOT leak into the error.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: None.
"""
observed = " ✱ Working…\n ⏵⏵ auto mode on (shift+tab to cycle)\n"
# A frame that would satisfy the readiness check, but only ever returned
# after the deadline has passed — mimicking the box repainting late.
late_ready = "────────────────\n \n────────────────\n Opus 4.8\n"
calls = {"n": 0}
def fake_capture(socket_path: str, tmux_target: str) -> str:
calls["n"] += 1
# First (and only, at timeout_s=0) in-loop capture: no box.
# Any later capture would be the box-present frame — which the
# rewritten gate must never fetch, since it does not re-capture.
return observed if calls["n"] == 1 else late_ready
monkeypatch.setattr("omnigent.claude_native_bridge._capture_pane", fake_capture)
with pytest.raises(RuntimeError) as excinfo:
claude_native_bridge._wait_for_claude_prompt_ready(
"/tmp/example/tmux.sock",
"claude:0.0",
timeout_s=0.0,
)
message = str(excinfo.value)
# The tail reflects what the loop observed, and the late box-present
# frame never appears — proving no post-deadline re-capture happened.
assert "auto mode on" in message
assert "" not in message
assert calls["n"] == 1
# ── _hook_record_from_jsonl_record: background_task_count ────────────────────
def test_hook_record_parses_stop_background_tasks() -> None:
"""
``Stop`` with ``background_tasks`` → ``background_task_count`` is set.
Claude Code fires the Stop hook with a ``background_tasks`` array when
shells are still running. The forwarder uses the count to decide whether
to publish ``waiting`` instead of ``idle``.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "Stop",
"background_tasks": [
{
"id": "abc123",
"type": "shell",
"status": "running",
"description": "Wait for CI",
"command": "sleep 120",
},
{
"id": "def456",
"type": "shell",
"status": "running",
"description": "Build check",
"command": "make build",
},
],
}
)
)
assert record.event_name == "Stop"
assert record.background_task_count == 2
def test_hook_record_stop_counts_only_running_background_tasks() -> None:
"""Terminal-status entries are excluded so a finished shell can't over-count.
Claude Code retains finished/stopped shells in the ``background_tasks``
array (claude-code #67895/#59456/#14049). Counting the raw length would
keep the "N background tasks still running" indicator lit after they exit,
so only non-terminal entries are counted — and an unknown/absent status
counts as running so a genuinely-live shell is never dropped.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "Stop",
"background_tasks": [
{"id": "a", "type": "shell", "status": "running"},
{"id": "b", "type": "shell", "status": "completed"},
{"id": "c", "type": "shell", "status": "failed"},
{"id": "d", "type": "shell", "status": "stopped"},
{"id": "e", "type": "shell", "status": "killed"},
# Unknown and absent statuses count as live (conservative —
# never under-count and re-hide a running shell).
{"id": "f", "type": "shell", "status": "queued"},
{"id": "g", "type": "shell"},
],
}
)
)
assert record.event_name == "Stop"
# running + queued + no-status = 3; completed/failed/stopped/killed excluded.
assert record.background_task_count == 3
def test_hook_record_stop_all_background_tasks_terminal_counts_zero() -> None:
"""Every shell finished → count is 0, dropping the indicator."""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "Stop",
"background_tasks": [
{"id": "a", "type": "shell", "status": "completed"},
{"id": "b", "type": "shell", "status": "killed"},
],
}
)
)
assert record.background_task_count == 0
def test_hook_record_stop_without_background_tasks() -> None:
"""
``Stop`` without ``background_tasks`` → ``background_task_count`` is 0.
"""
record = _hook_record_from_jsonl_record(_make_jsonl_record({"hook_event_name": "Stop"}))
assert record.event_name == "Stop"
assert record.background_task_count == 0
def test_hook_record_non_stop_event_has_zero_background_tasks() -> None:
"""
Non-Stop events always have ``background_task_count == 0``.
"""
record = _hook_record_from_jsonl_record(
_make_jsonl_record(
{
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
}
)
)
assert record.background_task_count == 0
# ── /model switching + pane readiness ───────────────────────────────────
#: Claude Code's interactive ``/model`` picker. Omnigent never drives it —
#: it types ``/model <id>`` — but a picker the person opened by hand covers
#: the input box, so the readiness gate has to recognise it.
_MODEL_PICKER_PANE = """\
Select model
Switch between Claude models. Your pick becomes the default for new sessions.
1. Default (recommended) Use the default model (currently Opus 4.8) · $5/$25 per Mtok
2. databricks-claude-opus-4-8 Custom Opus model
3. databricks-claude-sonnet-5 ✔ Custom Sonnet model
4. Haiku Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok
● High effort (default) ←/→ to adjust
Enter to set as default · s to use this session only · Esc to cancel
"""
# The ``/effort`` confirmation, as Claude Code 2.1.222 renders it: the same
# component as "Switch model?", titled for what is being switched.
_EFFORT_DIALOG_PANE = """\
⎿ Running /effort high
╭──────────────────────────────────────────────────────────╮
│ Change effort level? │
│ Your next response will be slower and use more tokens │
│ │
│ This conversation is cached for the current effort level. │
│ Switching to high means the full history gets re-read on │
│ your next message. │
│ │
1. Yes, switch to high │
│ 2. No, go back │
╰──────────────────────────────────────────────────────────╯
"""
# A tool permission prompt. It can render mid-turn, while a confirm watch is
# still polling, and its default answer approves the tool — so a stray Enter
# has to stay off it.
_PERMISSION_PROMPT_PANE = """\
╭──────────────────────────────────────────────────────────╮
│ Bash command │
│ │
│ rm -rf build │
│ │
│ Do you want to proceed? │
1. Yes │
│ 2. Yes, and don't ask again for rm commands in ~/repo │
│ 3. No, and tell Claude what to do differently (esc) │
╰──────────────────────────────────────────────────────────╯
"""
_IDLE_PANE = """\
⎿ Set model to Sonnet 5 and saved as your default for new sessions
──────────────────────────────
──────────────────────────────
? for shortcuts
"""
# The ctrl+r prompt-history search, as Claude Code 2.1.231 renders it. Its
# selected row carries the composer's glyph above the filter box's frame
# rule, so the readiness scan alone reads it as a mounted input box.
_REVERSE_SEARCH_PANE = """\
Search prompts · everywhere
↑ 23m ago When I run a claude code session in O…
4m ago continue
1m ago [Pasted text #1 +22 lines]
╭──────────────────────────────────────────────╮
│ ⌕ Filter history… │
╰──────────────────────────────────────────────╯
↑/↓ to nav · Enter to use · Esc to cancel · ctrl+s to scope
"""
# The expanded ``?`` shortcuts panel: the composer above it is fully usable,
# and its "! for shell mode" row is why shell mode has no occupied-input
# hint — matching it here would send Escape at a perfectly injectable pane.
_SHORTCUTS_PANEL_PANE = """\
──────────────────────────────
──────────────────────────────
! for shell mode double tap esc to clear input
/ for commands shift + tab to auto-accept edits
"""
def _draft_pane(command: str) -> str:
"""A pane whose composer holds *command*, typed but not yet submitted."""
return f"""\
──────────────────────────────
{command}
──────────────────────────────
? for shortcuts
"""
def _picker_bridge_dir(tmp_path: Path) -> Path:
"""
Create a bridge dir advertising a tmux pane.
:param tmp_path: Per-test temp directory.
:returns: The bridge directory.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
return bridge_dir
class _VirtualClock:
"""Clock whose ``sleep`` advances ``monotonic`` instead of blocking.
The dialog-confirm poll runs off ``monotonic``, so a no-op ``sleep``
alone would leave it spinning for its whole real-time budget.
"""
def __init__(self) -> None:
self.now = 0.0
def sleep(self, seconds: float) -> None:
self.now += seconds
def monotonic(self) -> float:
return self.now
def time(self) -> float:
return self.now
def _fake_tmux(
monkeypatch: pytest.MonkeyPatch,
panes: list[str],
) -> list[list[str]]:
"""
Patch tmux so sends are recorded and captures walk *panes*.
:param monkeypatch: Pytest monkeypatch fixture.
:param panes: Pane frames to return in order; the last one repeats.
:returns: The list send-keys invocations are appended to (argv tails).
"""
sends: list[list[str]] = []
frames = list(panes)
def _fake_run_tmux(socket_path: str, *args: str) -> None:
del socket_path
sends.append(list(args))
def _fake_capture(socket_path: str, tmux_target: str) -> str:
del socket_path, tmux_target
return frames.pop(0) if len(frames) > 1 else frames[0]
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
monkeypatch.setattr(claude_native_bridge, "_capture_pane", _fake_capture)
monkeypatch.setattr(claude_native_bridge, "time", _VirtualClock())
return sends
def test_a_model_switch_types_the_argument_form_and_confirms(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A model switch is ``/model <id>`` plus the confirm Enter.
Accepted trade-off: the argument form also saves the pick as the person's
global default for new sessions. The interactive picker avoided that write
but needed ~35s of screen-scraping automation to drive.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
dialog = " Switch model?\n This will invalidate the prompt cache.\n"
sends = _fake_tmux(
monkeypatch,
# Idle at the occupied-input check, then the typed command
# renders, the submit pops the dialog, the accept clears it —
# one capture per delivery stage.
[
_IDLE_PANE,
_draft_pane("/model databricks-claude-sonnet-5"),
dialog,
dialog,
_IDLE_PANE,
],
)
claude_native_bridge.inject_slash_command(
bridge_dir,
command="/model databricks-claude-sonnet-5",
auto_confirm=True,
confirm_hint=claude_native_bridge.SWITCH_MODEL_DIALOG_HINT,
)
assert [args[-1] for args in sends] == [
"C-u",
"/model databricks-claude-sonnet-5",
"Enter",
# The "Switch model?" confirmation, once it actually rendered.
"Enter",
]
# The id must go through literally, not as a tmux key name.
assert sends[1] == [
"send-keys",
"-l",
"-t",
"claude:0.0",
"/model databricks-claude-sonnet-5",
]
def test_confirm_tui_dialog_polls_instead_of_sleeping_a_fixed_interval(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The confirm Enter waits for the dialog rather than racing it.
The old fixed 0.3s sleep dropped the Enter on a warm session (the dialog
took 1.861s), leaving it open until the next injection failed.
"""
dialog = " Switch model?\n"
sends = _fake_tmux(monkeypatch, ["", "", dialog, _IDLE_PANE])
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.SWITCH_MODEL_DIALOG_HINT,
)
is True
)
# Exactly one Enter: the dialog verifiably closed on the first press, so
# neither a verify retry nor the blind fallback may add another.
assert [args[-1] for args in sends] == ["Enter"]
def test_a_matched_hint_confirms_before_the_poll_times_out(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The watch presses the moment the dialog is on screen, then only
captures long enough to see it close."""
captures: list[int] = []
dialog = " Switch model?\n"
frames = ["", dialog, _IDLE_PANE]
def _fake_capture(socket_path: str, tmux_target: str) -> str:
del socket_path, tmux_target
captures.append(1)
return frames[min(len(captures) - 1, len(frames) - 1)]
monkeypatch.setattr(claude_native_bridge, "_run_tmux", lambda *a: None)
monkeypatch.setattr(claude_native_bridge, "_capture_pane", _fake_capture)
monkeypatch.setattr(claude_native_bridge, "time", _VirtualClock())
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.SWITCH_MODEL_DIALOG_HINT,
)
is True
)
# Two watch captures (blank, dialog) + one verify capture (closed).
assert len(captures) == 3
def test_a_dialog_whose_title_drifted_still_gets_the_blind_fallback_enter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A hint that never matches must not leave our own dialog open forever.
Claude Code can rename a dialog between releases. Nothing recognisable is
on screen, so the timeout Enter is the only thing that can close it — and
on an idle pane it costs nothing.
"""
drifted = " Change the effort level?\n This invalidates the cache.\n"
sends = _fake_tmux(monkeypatch, [drifted])
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
timeout_s=0.0,
)
is False
)
assert [args[-1] for args in sends] == ["Enter"]
def test_an_effort_dialog_rendering_late_is_still_confirmed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The warm-session race: the dialog renders seconds after the command.
A fixed 0.3s sleep fired the Enter into an idle pane, and the dialog that
arrived at ~1.9s stayed open — the person's next message was typed into the
modal and swallowed. The watch has to outlive that settle.
"""
sends = _fake_tmux(
monkeypatch,
[_IDLE_PANE, _IDLE_PANE, _EFFORT_DIALOG_PANE, _IDLE_PANE],
)
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
is True
)
# One Enter, and only once the dialog was actually on screen.
assert [args[-1] for args in sends] == ["Enter"]
@pytest.mark.parametrize(
("name", "foreign_pane"),
[
("model picker", _MODEL_PICKER_PANE),
("permission prompt", _PERMISSION_PROMPT_PANE),
],
)
def test_a_foreign_dialog_rendering_during_the_watch_gets_no_enter(
monkeypatch: pytest.MonkeyPatch,
name: str,
foreign_pane: str,
) -> None:
"""Someone else's dialog must never take our confirm Enter.
Our dialog never pops, and mid-watch the person opens a ``/model`` picker
(whose Enter rewrites their global default) or Claude asks for a tool
permission (whose Enter approves it). Both stay untouched.
"""
del name
sends = _fake_tmux(monkeypatch, [_IDLE_PANE, _IDLE_PANE, foreign_pane])
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
is False
)
assert sends == []
def test_a_foreign_dialog_already_open_at_the_start_gets_no_enter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The same protection when the foreign surface was up all along."""
sends = _fake_tmux(monkeypatch, [_MODEL_PICKER_PANE])
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
timeout_s=0.0,
)
is False
)
assert sends == []
def test_a_swallowed_confirm_enter_is_retried_while_the_dialog_persists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An Enter the busy TUI dropped is re-sent while the dialog stays up.
The load that renders the dialog ~1.9s late also drops keystrokes
mid-repaint. A single unverified Enter then leaves the dialog parked and
every later delivery failing the readiness gate — the same wedge the poll
fixed, one step later. Each retry fires only while the dialog is
verifiably still on screen, so none can leak onto the returned composer.
"""
sends = _fake_tmux(
monkeypatch,
[_EFFORT_DIALOG_PANE] * 6 + [_IDLE_PANE],
)
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
is True
)
enters = [args[-1] for args in sends]
assert enters == ["Enter", "Enter"], (
f"Expected the swallowed Enter to be retried exactly once before the "
f"dialog closed; got {enters}."
)
def test_a_torn_capture_does_not_end_the_confirm_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An empty capture means "unknown", never "the dialog closed".
``capture-pane`` returns "" on a torn read under a busy repaint — the very
load that swallows the Enter. If that blank frame ended the verify, the
confirm would report success with the dialog still parked on the pane.
"""
sends = _fake_tmux(
monkeypatch,
[
_EFFORT_DIALOG_PANE,
"",
_EFFORT_DIALOG_PANE,
"",
_EFFORT_DIALOG_PANE,
"",
_IDLE_PANE,
],
)
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
is True
)
enters = [args[-1] for args in sends]
assert enters == ["Enter", "Enter"], (
f"A blank frame must keep the retry alive, not declare the dialog "
f"closed after the first Enter; got {enters}."
)
def test_a_dialog_that_never_closes_gives_up_instead_of_retrying_forever(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The confirm retry is bounded, so a stuck dialog can't hang the runner.
Every Enter is swallowed and the dialog never leaves the pane — the
unrecoverable case. The verify has to exhaust its budget and return,
leaving the persisted session value as the fallback. An unbounded retry
(a dropped deadline, or a torn-capture guard that never accepts a clean
frame) would instead spin forever on the injection thread, and the two
tests above would still pass because their dialogs do close.
"""
clock = _VirtualClock()
sends: list[list[str]] = []
def _fake_run_tmux(socket_path: str, *args: str) -> None:
del socket_path
sends.append(list(args))
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
# Nothing here ever clears the dialog, however many Enters arrive.
monkeypatch.setattr(
claude_native_bridge,
"_capture_pane",
lambda _s, _t: _EFFORT_DIALOG_PANE,
)
monkeypatch.setattr(claude_native_bridge, "time", clock)
assert (
claude_native_bridge._confirm_tui_dialog(
"/tmp/s.sock",
"claude:0.0",
hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
is True
)
# Gave up within the accept budget instead of spinning.
assert clock.now <= (
claude_native_bridge._CONFIRM_DIALOG_ACCEPT_TIMEOUT_S
+ claude_native_bridge._CLAUDE_READY_POLL_INTERVAL_S
), f"the confirm verify ran {clock.now}s, past its accept budget"
# And it did keep re-pressing while the dialog was verifiably still up.
enters = [args[-1] for args in sends]
assert len(enters) >= 2, f"the swallowed confirm Enter was never retried; got {enters}"
def test_a_slash_command_submit_waits_for_the_command_to_render(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The submit Enter is withheld until the typed command is in the box.
The TUI coalesces rapid stdin bursts: an Enter arriving while it is still
consuming the command folds in as a newline and the command sits unsent —
while the persisted session value claims the switch applied.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
events: list[str] = []
frames = ["", _IDLE_PANE, _draft_pane("/effort high"), _IDLE_PANE]
served = {"n": 0}
def _fake_run_tmux(socket_path: str, *args: str) -> None:
del socket_path
events.append(f"send:{args[-1]}")
def _fake_capture(socket_path: str, tmux_target: str) -> str:
del socket_path, tmux_target
events.append("capture")
frame = frames[min(served["n"], len(frames) - 1)]
served["n"] += 1
return frame
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
monkeypatch.setattr(claude_native_bridge, "_capture_pane", _fake_capture)
monkeypatch.setattr(claude_native_bridge, "time", _VirtualClock())
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
first_enter = events.index("send:Enter")
captures_before = sum(1 for event in events[:first_enter] if event == "capture")
# Blank (occupied-input check) + idle + draft: three captures before
# the Enter may fire.
assert captures_before == 3, (
f"Enter must wait for the command to render (expected 3 captures first); events: {events}"
)
def test_a_swallowed_slash_submit_enter_is_retried_while_the_draft_persists(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A submit Enter the busy TUI dropped is re-sent while the command sits.
Without the retry the command stays drafted, the session's persisted
effort diverges from the pane, and the next injection's C-u destroys the
only evidence. Retries fire only while the command is verifiably still in
the box, so none can reach the cleared composer or a dialog.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
sends = _fake_tmux(
monkeypatch,
[_IDLE_PANE] + [_draft_pane("/effort high")] * 8 + [_IDLE_PANE],
)
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
tails = [args[-1] for args in sends]
assert tails == ["C-u", "/effort high", "Enter", "Enter"], (
f"Expected the swallowed submit Enter to be retried once; got {tails}."
)
def test_a_slash_command_stuck_in_the_composer_fails_loud(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A command that never submits raises instead of reporting success.
The runner turns this into a 503 and the persisted session value stays
the authoritative fallback — an honest failure, not a silent divergence.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
_fake_tmux(monkeypatch, [_draft_pane("/effort high")])
with pytest.raises(RuntimeError, match="was not delivered"):
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
def test_a_slash_command_draft_that_never_renders_submits_blind(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Fail-soft: an unidentifiable draft falls back to one unverified Enter.
Some panes never render the typed command where the check can see it — a
custom statusline holding the prompt glyph, or captures that stay torn.
Its absence after Enter then proves nothing, so verification would pass
trivially, and failing loud would break sessions that are working fine.
Exactly one Enter, no retries, no raise: the pre-verification contract,
which is the difference between this and the stuck-composer case above.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
# The composer never shows the command, so the draft is never identified.
sends = _fake_tmux(monkeypatch, [_IDLE_PANE])
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
tails = [args[-1] for args in sends]
assert tails == ["C-u", "/effort high", "Enter"], (
f"An unverifiable draft must submit blind exactly once; got {tails}."
)
def test_an_effort_switch_with_a_swallowed_confirm_leaves_the_pane_usable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The reported wedge, end to end: the pane is injectable again afterwards.
Live repro: a web effort switch had its confirm Enter dropped, the dialog
stayed parked over the composer, and every later message failed the
readiness gate with "terminal did not become ready within 30.0s". Driving
the whole injection and then asserting that gate — not just counting
Enters — is what ties both verified stages to the symptom people hit.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
tui = {"pane": _IDLE_PANE, "confirm_enters": 0}
def _fake_run_tmux(socket_path: str, *args: str) -> None:
del socket_path
if "-l" in args:
tui["pane"] = _draft_pane("/effort high")
elif args[-1] == "Enter":
if claude_native_bridge.EFFORT_DIALOG_HINT in tui["pane"]:
tui["confirm_enters"] += 1
# The first confirm Enter is swallowed; the retry lands.
if tui["confirm_enters"] >= 2:
tui["pane"] = _IDLE_PANE
else:
tui["pane"] = _EFFORT_DIALOG_PANE # the submit ran /effort
monkeypatch.setattr(claude_native_bridge, "_run_tmux", _fake_run_tmux)
monkeypatch.setattr(claude_native_bridge, "_capture_pane", lambda _s, _t: tui["pane"])
monkeypatch.setattr(claude_native_bridge, "time", _VirtualClock())
claude_native_bridge.inject_slash_command(
bridge_dir,
command="/effort high",
auto_confirm=True,
confirm_hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
assert tui["confirm_enters"] >= 2, "the swallowed confirm Enter was never retried"
# The very check the next message's readiness gate runs — the parked
# dialog used to fail it for 30s per message.
assert claude_native_bridge.claude_pane_ready(bridge_dir) is True
def test_auto_confirm_without_a_dialog_hint_is_rejected(tmp_path: Path) -> None:
"""A confirm Enter with nothing to aim at is what hit foreign dialogs."""
bridge_dir = _picker_bridge_dir(tmp_path)
with pytest.raises(ValueError, match="confirm_hint"):
claude_native_bridge.inject_slash_command(
bridge_dir,
command="/effort high",
auto_confirm=True,
)
def test_an_effort_injection_with_no_dialog_completes_without_hanging(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The no-dialog case: the fallback Enter lands on an empty prompt, harmlessly."""
bridge_dir = _picker_bridge_dir(tmp_path)
sends = _fake_tmux(
monkeypatch,
[_IDLE_PANE, _draft_pane("/effort high"), _IDLE_PANE],
)
claude_native_bridge.inject_slash_command(
bridge_dir,
command="/effort high",
auto_confirm=True,
confirm_hint=claude_native_bridge.EFFORT_DIALOG_HINT,
)
assert [args[-1] for args in sends] == ["C-u", "/effort high", "Enter", "Enter"]
@pytest.mark.parametrize(
"occupied_pane",
[_REVERSE_SEARCH_PANE, _MODEL_PICKER_PANE],
ids=["reverse-search", "model-picker"],
)
def test_inject_user_message_restores_an_occupied_input_box_first(
occupied_pane: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A surface left covering the composer is dismissed before typing.
A ctrl+r history search (or hand-opened ``/model`` picker) left up
from the embedded terminal swallows injected keystrokes — the search
even replays an old prompt on Enter — so a chat message silently
never arrived. The injection must Escape the surface first (its own
documented dismissal), restoring the empty input box, then deliver
the message normally.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._CLAUDE_READY_POLL_INTERVAL_S", 0.01)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
captured: list[list[str]] = []
# The surface covers the pane until Escape dismisses it; afterwards
# the fake behaves like the live input box (paste deposits the
# draft, Enter clears it).
tui = {"pane": occupied_pane}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Simulate a pane whose occupying surface closes on Escape.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the
simulated pane, other calls return rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if cmd[-1] == "Escape":
tui["pane"] = " "
if "paste-buffer" in cmd:
tui["pane"] = " restore my composer"
if cmd[-1] == "Enter":
tui["pane"] = " "
captured.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content="restore my composer")
tails = [cmd[-1] for cmd in captured]
# Escape (dismiss the surface) must precede every delivery keystroke.
assert tails[:3] == ["Escape", "C-a", "C-k"], (
f"Expected the occupying surface to be Escaped before the clear; got {tails}."
)
assert tails.count("Escape") == 1, f"One sighting, one Escape — got {tails.count('Escape')}."
assert tails[-1] == "Enter"
def test_inject_user_message_retries_a_swallowed_occupied_input_escape(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
An Escape the busy TUI dropped is re-sent while the surface remains.
A repaint can swallow the dismissal exactly like it swallows submit
Enters. A one-shot Escape would then paste into the still-open
search filter — the original lost-message bug, made intermittent.
Retries fire only while the surface is verifiably on screen, so none
can reach the restored composer (where Escape interrupts a turn).
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._CLAUDE_READY_POLL_INTERVAL_S", 0.01)
monkeypatch.setattr(
"omnigent.claude_native_bridge._OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S", 0.0
)
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/example/tmux.sock"),
tmux_target="claude:0.0",
)
escapes = {"n": 0}
tui = {"pane": _REVERSE_SEARCH_PANE}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""
Swallow the first Escape; dismiss the search on the second.
:param cmd: Argv list passed to subprocess.run.
:param kwargs: Subprocess kwargs (ignored).
:returns: Fake CompletedProcess; capture-pane returns the
simulated pane, other calls return rc=0.
"""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if cmd[-1] == "Escape":
escapes["n"] += 1
if escapes["n"] >= 2:
tui["pane"] = " "
if "paste-buffer" in cmd:
tui["pane"] = " hello"
if cmd[-1] == "Enter":
tui["pane"] = " "
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message(bridge_dir, content="hello")
assert escapes["n"] == 2, (
f"Expected the swallowed Escape to be retried exactly once, got {escapes['n']}."
)
def test_inject_slash_command_restores_an_occupied_input_box_first(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The same reclaim guards slash commands (e.g. a routed model switch).
Without it, a ``/model`` typed while the ctrl+r search is up lands in
the search filter and the pane silently keeps its old model.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
sends = _fake_tmux(
monkeypatch,
# Search up at the occupied-input check, idle after the Escape,
# then the typed command renders and the submit clears it.
[_REVERSE_SEARCH_PANE, _IDLE_PANE, _draft_pane("/effort high"), _IDLE_PANE],
)
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
assert [args[-1] for args in sends] == ["Escape", "C-u", "/effort high", "Enter"]
def test_the_shortcuts_panel_is_not_treated_as_an_occupied_input(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
No Escape at a usable composer, even with the ``?`` panel expanded.
The panel lists "! for shell mode" verbatim — the string shell mode
itself renders — while the composer right above it is injectable. An
Escape here would be the blind press the restore must never make
(mid-turn it interrupts the response).
"""
bridge_dir = _picker_bridge_dir(tmp_path)
sends = _fake_tmux(
monkeypatch,
[_SHORTCUTS_PANEL_PANE, _draft_pane("/effort high"), _IDLE_PANE],
)
claude_native_bridge.inject_slash_command(bridge_dir, command="/effort high")
assert [args[-1] for args in sends] == ["C-u", "/effort high", "Enter"]
def test_claude_pane_ready_is_true_only_at_an_idle_input_box(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The claude-native "an injection may land now" signal, which doubles as the
settle signal for the routing replay.
A ``/model`` picker the person opened, or either cache-invalidation
confirmation, swallows typed text — so a mounted input box with none of
them on top of it is what readiness means. A blocked ``UserPromptSubmit``
starts no turn either, so there is no turn id to wait out; this is what
says the replay may land.
"""
bridge_dir = _picker_bridge_dir(tmp_path)
frames = {"pane": _IDLE_PANE}
monkeypatch.setattr(
claude_native_bridge,
"_capture_pane",
lambda _s, _t: frames["pane"],
)
assert claude_native_bridge.claude_pane_ready(bridge_dir) is True
frames["pane"] = _MODEL_PICKER_PANE
assert claude_native_bridge.claude_pane_ready(bridge_dir) is False
frames["pane"] = " Switch model?\n"
assert claude_native_bridge.claude_pane_ready(bridge_dir) is False
frames["pane"] = _EFFORT_DIALOG_PANE
assert claude_native_bridge.claude_pane_ready(bridge_dir) is False
def test_claude_pane_ready_is_false_without_an_advertised_pane(tmp_path: Path) -> None:
assert claude_native_bridge.claude_pane_ready(tmp_path / "nope") is False
def test_message_display_shell_command_round_trips(tmp_path: Path) -> None:
"""The generated MessageDisplay shell appender feeds the deltas reader.
Runs the exact command the settings install through /bin/sh with a
pretty-printed raw payload (proving the one-line flattening), then
parses it back with the same reader the forwarder uses — the full
chunk pipeline minus Claude itself.
"""
args = augment_claude_args((), bridge_dir=tmp_path)
settings = _load_invocation_settings(args)
command = settings["hooks"]["MessageDisplay"][0]["hooks"][0]["command"]
assert "python" not in command, f"per-chunk path must not spawn python: {command}"
payload = {
"hook_event_name": "MessageDisplay",
"message_id": "m1",
"index": 0,
"final": False,
"delta": "Hello world",
}
for i in range(2):
payload["index"] = i
result = subprocess.run(
["/bin/sh", "-c", command],
input=json.dumps(payload, indent=2),
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
parsed = read_message_deltas_from_offset(tmp_path, 0)
assert [(d.message_id, d.index, d.delta) for d in parsed.deltas] == [
("m1", 0, "Hello world"),
("m1", 1, "Hello world"),
]
def test_statusline_shell_command_captures_and_chains(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The statusLine shim captures raw stdin atomically and chains the user command.
The captured raw file must normalize into context.json via the
forwarder-side sync, and the chained command must receive the exact
stdin Claude sent.
"""
from omnigent.claude_native_status import CONTEXT_RAW_FILE, sync_raw_status_context
chain_out = tmp_path / "chain_out.json"
monkeypatch.setattr(
"omnigent.claude_native_bridge.read_user_status_line_command",
lambda: f"cat > {chain_out}",
)
args = augment_claude_args((), bridge_dir=tmp_path)
settings = _load_invocation_settings(args)
command = settings["statusLine"]["command"]
assert "python" not in command, f"statusLine path must not spawn python: {command}"
payload = json.dumps(
{
"context_window": {"context_window_size": 500_000},
"model": {"id": "claude-opus-4-8"},
}
)
result = subprocess.run(
["/bin/sh", "-c", command], input=payload, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert (tmp_path / CONTEXT_RAW_FILE).read_text("utf-8") == payload
assert chain_out.read_text("utf-8") == payload
sync_raw_status_context(tmp_path, None)
written = json.loads((tmp_path / "context.json").read_text("utf-8"))
assert written == {"context_window_size": 500_000, "model": "claude-opus-4-8"}
_PRE_TOOL_USE_PAYLOAD: dict[str, object] = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "true"},
}
class _ScriptedPolicyClient:
"""Fake runner policy client with a scripted body or failure.
:param body: JSON body for every response, or ``None`` to raise.
"""
def __init__(self, body: dict[str, object] | None) -> None:
"""Store the script.
:param body: Response payload; ``None`` makes every call raise.
"""
self.body = body
self.calls = 0
async def post(self, url: str, json: dict[str, object] | None = None) -> SimpleNamespace:
"""Return the scripted verdict or raise a transport error.
:param url: Evaluate path (ignored).
:param json: Forwarded EvaluationRequest (ignored).
:returns: Minimal httpx-Response-shaped namespace.
"""
import json as _json
del url, json
self.calls += 1
if self.body is None:
raise ConnectionError("scripted transport failure")
raw = _json.dumps(self.body).encode("utf-8")
return SimpleNamespace(
status_code=200, content=raw, headers={"Content-Type": "application/json"}
)
def _hook_relay(tmp_path, monkeypatch, client):
"""Boot a relay wired with *client* for the hook-evaluate tests."""
from omnigent import pi_native_bridge
monkeypatch.setattr("omnigent.pi_native_bridge._BRIDGE_ROOT", tmp_path / "pi-native")
bridge_dir = pi_native_bridge.prepare_bridge_dir("conv_hook_eval")
async def _executor(name: str, arguments: dict[str, object]) -> dict[str, object]:
"""Accept any relayed tool call."""
del name, arguments
return {}
relay = claude_native_bridge.start_tool_relay(
bridge_dir=bridge_dir,
tools=[],
tool_executor=_executor,
loop=asyncio.get_running_loop(),
policy_client=client, # type: ignore[arg-type]
session_id="conv_hook_eval",
)
return relay, bridge_dir
def _relay_request_raw(bridge_dir: Path, path: str, payload: dict[str, object]) -> str:
"""POST to the relay and return the raw response body text."""
import urllib.request
info = json.loads((bridge_dir / claude_native_bridge._TOOL_RELAY_FILE).read_text("utf-8"))
req = urllib.request.Request(
info["url"].rstrip("/") + path,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {info['token']}",
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8")
@pytest.mark.asyncio
async def test_hook_evaluate_endpoint_allows(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An ALLOW verdict answers empty (no opinion), evaluating every event.
The endpoint owns the whole transform chain, so an ALLOW must reach
the curl hook as an empty body — and every event must consult the
server, since verdicts are content-dependent and nothing is cached.
"""
client = _ScriptedPolicyClient({"result": "POLICY_ACTION_ALLOW"})
relay, bridge_dir = _hook_relay(tmp_path, monkeypatch, client)
try:
for _ in range(2):
body = await asyncio.to_thread(
_relay_request_raw,
bridge_dir,
"/hook/claude/evaluate-policy",
_PRE_TOOL_USE_PAYLOAD,
)
assert body == "", f"allow must be empty hook output, got {body!r}"
assert client.calls == 2, f"every event must evaluate upstream, saw {client.calls}"
# The relay wrote the shell-sourceable env file the curl hooks use.
env_text = (bridge_dir / claude_native_bridge._TOOL_RELAY_ENV_FILE).read_text("utf-8")
assert "OMNIGENT_RELAY_URL=" in env_text and "OMNIGENT_RELAY_TOKEN=" in env_text
finally:
relay.close()
@pytest.mark.asyncio
async def test_hook_evaluate_endpoint_returns_deny_hook_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A DENY verdict comes back as ready-made PreToolUse hook output."""
client = _ScriptedPolicyClient({"result": "POLICY_ACTION_DENY", "reason": "blocked by test"})
relay, bridge_dir = _hook_relay(tmp_path, monkeypatch, client)
try:
body = await asyncio.to_thread(
_relay_request_raw,
bridge_dir,
"/hook/claude/evaluate-policy",
_PRE_TOOL_USE_PAYLOAD,
)
output = json.loads(body)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
assert "blocked by test" in output["hookSpecificOutput"]["permissionDecisionReason"]
finally:
relay.close()
@pytest.mark.asyncio
async def test_hook_evaluate_endpoint_fails_closed_on_unreachable_upstream(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Upstream failure denies PreToolUse and stays open for PostToolUse."""
client = _ScriptedPolicyClient(None)
relay, bridge_dir = _hook_relay(tmp_path, monkeypatch, client)
try:
body = await asyncio.to_thread(
_relay_request_raw,
bridge_dir,
"/hook/claude/evaluate-policy",
_PRE_TOOL_USE_PAYLOAD,
)
output = json.loads(body)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
post_body = await asyncio.to_thread(
_relay_request_raw,
bridge_dir,
"/hook/claude/evaluate-policy",
{**_PRE_TOOL_USE_PAYLOAD, "hook_event_name": "PostToolUse", "tool_output": "x"},
)
assert post_body == "", "PostToolUse must fail open (tool already ran)"
finally:
relay.close()
@pytest.mark.asyncio
async def test_curl_evaluate_policy_command_round_trips(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The generated curl hook command works against a live relay.
Runs the exact PreToolUse command the settings install through
/bin/sh: a DENY upstream must surface as deny hook output, and a
bridge dir with no relay must replay stdin into the Python hook
(which owns the direct-server path and fail-closed shaping).
"""
client = _ScriptedPolicyClient({"result": "POLICY_ACTION_DENY", "reason": "curl says no"})
relay, bridge_dir = _hook_relay(tmp_path, monkeypatch, client)
try:
args = augment_claude_args((), bridge_dir=bridge_dir, ap_server_url="http://127.0.0.1:9")
settings = _load_invocation_settings(args)
pre_entries = [
entry for entry in settings["hooks"]["PreToolUse"] if "matcher" not in entry
]
command = pre_entries[0]["hooks"][0]["command"]
assert "curl" in command and "evaluate-policy" in command
# The Python hook must appear only as the relay-less fallback,
# after the curl fast path.
assert command.index("curl") < command.index("claude_native_hook")
# Off-loop: the relay proxies through this test's event loop, so a
# blocking subprocess.run here would deadlock the curl round trip.
result = await asyncio.to_thread(
subprocess.run,
["/bin/sh", "-c", command],
input=json.dumps(_PRE_TOOL_USE_PAYLOAD),
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
assert "curl says no" in output["hookSpecificOutput"]["permissionDecisionReason"]
finally:
relay.close()
# No relay env file (fresh session before its first dispatched turn,
# or runner gone): stdin replays into the Python hook, which takes
# its direct-server path (unreachable here) and fails closed with
# its own shaping — the pre-curl behavior.
bare_dir = tmp_path / "no-relay"
bare_dir.mkdir()
args = augment_claude_args((), bridge_dir=bare_dir, ap_server_url="http://127.0.0.1:9")
(bare_dir / "bridge.json").write_text(
json.dumps({"active_session_id": "conv_no_relay"}), encoding="utf-8"
)
settings = _load_invocation_settings(args)
pre_entries = [entry for entry in settings["hooks"]["PreToolUse"] if "matcher" not in entry]
result = subprocess.run(
["/bin/sh", "-c", pre_entries[0]["hooks"][0]["command"]],
input=json.dumps(_PRE_TOOL_USE_PAYLOAD),
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
assert output["hookSpecificOutput"]["permissionDecisionReason"]