opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
-92
View File
@@ -360,95 +360,3 @@ class TestQueueConsumptionAfterCompletion:
e.text for e in runner._queued_events[session_key]
]
assert collected == texts
class TestBusyInputModeQueueFifo:
"""Regression coverage for issue #28503.
``busy_input_mode: queue`` rapid follow-ups used to silently overwrite
a single pending slot, losing every message except the last. The
runner's busy/queue/steer-fallback entry point now routes through
the same FIFO infrastructure as ``/queue``, so each follow-up gets
its own turn in arrival order.
"""
def _make_runner_and_adapter(self):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
adapter = _StubAdapter()
runner.adapters = {Platform.TELEGRAM: adapter}
return runner, adapter
def _text_event(self, text: str) -> MessageEvent:
source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM)
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=f"m-{text}",
)
def test_rapid_text_followups_are_queued_in_fifo_order(self):
"""Five rapid texts in queue mode must all survive (none silently dropped)."""
runner, adapter = self._make_runner_and_adapter()
session_key = "telegram:user:fifo"
texts = ["one", "two", "three", "four", "five"]
for text in texts:
runner._queue_or_replace_pending_event(session_key, self._text_event(text))
# Head slot keeps the first; overflow keeps the rest in order.
assert adapter._pending_messages[session_key].text == "one"
assert [e.text for e in runner._queued_events[session_key]] == [
"two",
"three",
"four",
"five",
]
assert runner._queue_depth(session_key, adapter=adapter) == len(texts)
def test_queue_respects_bounded_cap(self):
"""Beyond the per-session cap, follow-ups are dropped (with a warning)."""
from gateway.run import GatewayRunner
runner, adapter = self._make_runner_and_adapter()
session_key = "telegram:user:cap"
cap = GatewayRunner._BUSY_QUEUE_MAX_PENDING
for i in range(cap + 5):
runner._queue_or_replace_pending_event(
session_key, self._text_event(f"msg-{i:03d}")
)
# Exactly ``cap`` follow-ups retained (head + cap-1 in overflow).
assert runner._queue_depth(session_key, adapter=adapter) == cap
assert adapter._pending_messages[session_key].text == "msg-000"
# The last accepted overflow item is msg-{cap-1}.
assert runner._queued_events[session_key][-1].text == f"msg-{cap - 1:03d}"
def test_photo_burst_still_merges_in_head_slot(self):
"""Photo bursts must keep album-merge semantics, not split into N turns."""
runner, adapter = self._make_runner_and_adapter()
session_key = "telegram:user:burst"
source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM)
for i in range(3):
runner._queue_or_replace_pending_event(
session_key,
MessageEvent(
text="",
message_type=MessageType.PHOTO,
source=source,
message_id=f"p-{i}",
media_urls=[f"http://example.com/{i}.jpg"],
media_types=["image/jpeg"],
),
)
# Single merged head event with all three media URLs.
assert session_key not in runner._queued_events or not runner._queued_events[session_key]
head = adapter._pending_messages[session_key]
assert head.message_type == MessageType.PHOTO
assert len(head.media_urls) == 3