项目文件夹

文件
2026-07-13 13:12:00 +08:00

580 行
22 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Tests for Kiro native tmux bridge helpers."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
import omnigent.kiro_native_bridge as bridge
from omnigent.kiro_native_bridge import (
KIRO_ACP_RECORD_PATH_ENV_VAR,
KIRO_NATIVE_BRIDGE_DIR_ENV_VAR,
acp_record_path,
build_kiro_native_terminal_env,
inject_user_message,
send_kiro_permission_verdict,
write_forwarder_ready,
write_tmux_target,
)
_READY_PANE = (
"old output\n────────────────\nkiro_default · auto\n\n ask a question or describe a task ↵"
)
_PERMISSION_PANE = """
────────────────────────────────────────────────────────────────────────────────
↓ Shell pwd
shell requires approval
Yes, single permission
Trust, always allow in this session
No (Tab to edit)
────────────────────────────────────────────────────────────────────────────────
ESC to close · Tab to edit
"""
_PERMISSION_PANE_TRUST_FOCUSED = _PERMISSION_PANE.replace(
" Yes, single permission\n Trust, always allow in this session",
" Yes, single permission\n Trust, always allow in this session",
)
_PERMISSION_PANE_REJECT_FOCUSED = _PERMISSION_PANE.replace(
" Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)",
" Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)",
)
_PERMISSION_PANE_DATE = _PERMISSION_PANE.replace("↓ Shell pwd", "↓ Shell date")
def _install_fake_tmux(
monkeypatch: pytest.MonkeyPatch,
*,
pane_outputs: list[str] | None = None,
) -> list[list[str]]:
"""Replace subprocess.run with a successful tmux stub."""
calls: list[list[str]] = []
captures = list(pane_outputs or [_READY_PANE])
last_capture = captures[-1]
def _fake_run(args: list[str], **_kwargs: Any) -> SimpleNamespace:
nonlocal last_capture
calls.append(args)
if "capture-pane" in args:
if captures:
last_capture = captures.pop(0)
return SimpleNamespace(
returncode=0,
stdout=last_capture,
stderr="",
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(subprocess, "run", _fake_run)
return calls
def test_inject_user_message_does_not_wait_for_forwarder_on_fresh_kiro_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A brand-new Kiro session has no JSONL yet, so injection cannot require it."""
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(monkeypatch)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
assert any(call[-1] == "Enter" for call in calls)
# Message text is delivered via a bracketed paste, never ``send-keys -l``.
assert any("load-buffer" in call for call in calls)
assert any("paste-buffer" in call and "-p" in call for call in calls)
assert not any("-l" in call for call in calls)
def test_build_terminal_env_adds_bridge_dir_and_acp_record_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "bridge-root")
env = build_kiro_native_terminal_env("conv_kiro", source_env={"PATH": "/usr/bin"})
bridge_dir = bridge.bridge_dir_for_session_id("conv_kiro")
assert env[KIRO_NATIVE_BRIDGE_DIR_ENV_VAR] == str(bridge_dir)
assert env[KIRO_ACP_RECORD_PATH_ENV_VAR] == str(acp_record_path(bridge_dir))
assert env["PATH"] == "/usr/bin"
def test_send_kiro_permission_verdict_accepts_default_option(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_PERMISSION_KEY_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_ENTER_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(monkeypatch, pane_outputs=[_PERMISSION_PANE])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
send_kiro_permission_verdict(
bridge_dir, action="accept", expected_title="Running: pwd", timeout_s=0.1
)
sent_keys = [call[-1] for call in calls if "send-keys" in call]
assert sent_keys == ["Enter"]
def test_send_kiro_permission_verdict_refuses_accept_when_focus_drifts_after_settle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Accept re-validates the allow row after the settle delay before Enter."""
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_KEY_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_ENTER_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(
monkeypatch,
pane_outputs=[_PERMISSION_PANE, _PERMISSION_PANE_TRUST_FOCUSED],
)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="allow option was not safely focused"):
send_kiro_permission_verdict(
bridge_dir, action="accept", expected_title="Running: pwd", timeout_s=0.1
)
sent_keys = [call[-1] for call in calls if "send-keys" in call]
assert sent_keys == []
def test_send_kiro_permission_verdict_declines_with_slow_navigation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_PERMISSION_KEY_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_ENTER_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(
monkeypatch,
pane_outputs=[
_PERMISSION_PANE,
_PERMISSION_PANE_REJECT_FOCUSED,
],
)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
send_kiro_permission_verdict(
bridge_dir, action="decline", expected_title="Running: pwd", timeout_s=0.1
)
sent_keys = [call[-1] for call in calls if "send-keys" in call]
assert sent_keys == ["Down", "Down", "Enter"]
def test_send_kiro_permission_verdict_requires_visible_permission_prompt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch, pane_outputs=[_READY_PANE])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="permission prompt was not safely focused"):
send_kiro_permission_verdict(bridge_dir, action="accept", timeout_s=0.01)
def test_send_kiro_permission_verdict_refuses_when_focus_moved_to_trust(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch, pane_outputs=[_PERMISSION_PANE_TRUST_FOCUSED])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="permission prompt was not safely focused"):
send_kiro_permission_verdict(
bridge_dir, action="accept", expected_title="Running: pwd", timeout_s=0.01
)
def test_send_kiro_permission_verdict_refuses_when_prompt_title_differs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch, pane_outputs=[_PERMISSION_PANE])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="permission prompt was not safely focused"):
send_kiro_permission_verdict(
bridge_dir, action="accept", expected_title="Running: date", timeout_s=0.01
)
def test_send_kiro_permission_verdict_ignores_matching_text_outside_active_prompt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
pane = "old transcript mentioned Running: pwd\n" + _PERMISSION_PANE_DATE
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch, pane_outputs=[pane])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="permission prompt was not safely focused"):
send_kiro_permission_verdict(
bridge_dir, action="accept", expected_title="Running: pwd", timeout_s=0.01
)
def test_send_kiro_permission_verdict_refuses_decline_when_reject_not_focused(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_KEY_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_PERMISSION_ENTER_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(
monkeypatch,
pane_outputs=[_PERMISSION_PANE, _PERMISSION_PANE_TRUST_FOCUSED],
)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="reject option was not safely focused"):
send_kiro_permission_verdict(
bridge_dir, action="decline", expected_title="Running: pwd", timeout_s=0.01
)
sent_keys = [call[-1] for call in calls if "send-keys" in call]
assert sent_keys == ["Down", "Down"]
def test_inject_user_message_waits_for_forwarder_on_resumed_kiro_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A resumed Kiro session waits for JSONL forwarder catch-up before typing."""
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(monkeypatch)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
requires_forwarder_ready=True,
)
write_forwarder_ready(bridge_dir)
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
assert any(call[-1] == "Enter" for call in calls)
def test_inject_user_message_waits_for_kiro_input_prompt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A restarted Kiro TUI must render its input prompt before typing."""
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(
monkeypatch,
pane_outputs=[
"Kiro loading...",
_READY_PANE,
],
)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
capture_indexes = [index for index, call in enumerate(calls) if "capture-pane" in call]
paste_index = next(index for index, call in enumerate(calls) if "paste-buffer" in call)
assert len(capture_indexes) >= 2
assert max(capture_indexes[:2]) < paste_index
def test_inject_user_message_fails_when_kiro_input_prompt_never_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Lost first input should fail instead of typing into a booting pane."""
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch, pane_outputs=["Kiro loading..."])
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
with pytest.raises(RuntimeError, match="input prompt was not ready"):
inject_user_message(bridge_dir, content="hello", timeout_s=0.01)
def test_paste_payload_bytes_encodes_line_breaks_as_cr() -> None:
"""Newlines (incl. CRLF/CR) become CR, tabs survive, other control bytes drop.
The TUI treats a bracketed-paste CR as an in-draft newline, so encoding all
line breaks to CR is what keeps a multi-line message a single draft. A stray
ESC (or other control byte) would close the bracketed paste early, so those
are dropped.
"""
assert bridge._paste_payload_bytes("a\nb\r\nc\rd") == b"a\rb\rc\rd"
assert bridge._paste_payload_bytes("keep\ttab") == b"keep\ttab"
assert bridge._paste_payload_bytes("drop\x1bESC") == b"dropESC"
def test_inject_user_message_multiline_routes_through_bracketed_paste(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A multi-line web message is pasted whole, not typed line-by-line.
The bug: ``send-keys -l`` delivers the interior newlines as Enter keys, so
the first line submits on its own. The fix routes the message through a
single bracketed paste (``paste-buffer -p``) — the CR encoding the TUI needs
to keep the breaks as draft data is covered by
:func:`test_paste_payload_bytes_encodes_line_breaks_as_cr`. Here we just pin
that multi-line content never reaches ``send-keys -l`` and is committed by a
single Enter.
"""
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
bridge_dir = tmp_path / "bridge"
calls = _install_fake_tmux(monkeypatch)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
)
inject_user_message(bridge_dir, content="line one\nline two\nline three", timeout_s=0.1)
assert any("paste-buffer" in call and "-p" in call for call in calls)
assert not any("-l" in call for call in calls)
# Exactly one Enter commits the whole draft — not one per line.
assert sum(1 for call in calls if call[-1] == "Enter") == 1
def test_inject_user_message_fails_when_resumed_forwarder_is_not_ready(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A resumed first message must fail instead of being pasted too early."""
bridge_dir = tmp_path / "bridge"
_install_fake_tmux(monkeypatch)
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/tmux.sock"),
tmux_target="main",
requires_forwarder_ready=True,
)
with pytest.raises(RuntimeError, match="session forwarder was not ready"):
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
def test_draft_in_input_region_ignores_matching_history_and_baseline() -> None:
"""Short messages like '2' must match only a changed Kiro input region."""
baseline = "kiro_default · auto · ◔ 2%\n\n ask a question or describe a task ↵"
pane_with_history_only = "2\n\nold answer\n────────────────\n" + baseline
pane_with_draft = "old 2\n────────────────\nkiro_default · auto · ◔ 2%\n\n 2"
assert not bridge._draft_in_input_region(pane_with_history_only, "2", baseline)
assert bridge._draft_in_input_region(pane_with_draft, "2", baseline)
def test_draft_in_input_region_ignores_kiro_chrome_for_short_messages() -> None:
"""One-character prompts must not match cwd, branch, or placeholder chrome."""
baseline = (
"kiro_default · auto · ◔ 3% ~/Work/omnigent · "
"(feat/kiro-cli-harness)\n\n ask a question or describe a task ↵"
)
pane_after_submit = (
"c\n\n🙂\n────────────────\nkiro_default · auto · ◔ 4% "
"~/Work/omnigent · (feat/kiro-cli-harness)\n\n "
"ask a question or describe a task ↵\n/copy to clipboard"
)
pane_with_draft = (
"old answer\n────────────────\nkiro_default · auto · ◔ 3% "
"~/Work/omnigent · (feat/kiro-cli-harness)\n\n c"
)
assert not bridge._draft_in_input_region(pane_after_submit, "c", baseline)
assert bridge._draft_in_input_region(pane_with_draft, "c", baseline)
def test_inject_interrupt_sends_escape(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Interrupt cancels the running turn with a single Escape, nothing else.
Verified against kiro-cli 2.10.0: Escape stops generation and leaves an empty
composer, so (unlike cursor) there is no draft to clear afterwards.
"""
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
bridge, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(bridge, "_run_tmux", lambda _sock, *args: calls.append(args))
bridge.inject_interrupt(tmp_path)
assert calls == [("send-keys", "-t", "t", "Escape")]
def test_kill_session_kills_target(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Hard-stop kills the tmux session (ends ``kiro-cli`` and the pane)."""
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
bridge, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
)
monkeypatch.setattr(bridge, "_run_tmux", lambda _sock, *args: calls.append(args))
bridge.kill_session(tmp_path)
assert calls == [("kill-session", "-t", "t")]
def test_write_mcp_bridge_config_writes_token_idempotently(tmp_path: Path) -> None:
"""serve-mcp's bridge.json gets a token; a second call keeps the same one."""
bridge_dir = tmp_path / "bridge"
bridge.write_mcp_bridge_config(bridge_dir)
config_path = bridge_dir / "bridge.json"
first = json.loads(config_path.read_text())
assert isinstance(first.get("token"), str) and first["token"]
bridge.write_mcp_bridge_config(bridge_dir)
assert json.loads(config_path.read_text())["token"] == first["token"]
def test_build_kiro_mcp_config_targets_serve_mcp(tmp_path: Path) -> None:
"""The mcp.json entry runs the shared serve-mcp against the bridge dir."""
bridge_dir = tmp_path / "bridge"
server = bridge.build_kiro_mcp_config(bridge_dir, python_executable="/usr/bin/python3")[
"mcpServers"
]["omnigent"]
assert server["command"] == "/usr/bin/python3"
assert server["args"] == [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
]
# Defaults to the running interpreter when no executable is given.
default_cmd = bridge.build_kiro_mcp_config(bridge_dir)["mcpServers"]["omnigent"]["command"]
assert default_cmd == sys.executable
def test_write_kiro_workspace_mcp_config_merges_preserving_user_servers(tmp_path: Path) -> None:
"""The Omnigent server is merged into <workspace>/.kiro/settings/mcp.json
without clobbering a user's pre-existing workspace servers."""
workspace = tmp_path / "repo"
settings = workspace / ".kiro" / "settings"
settings.mkdir(parents=True)
(settings / "mcp.json").write_text(
json.dumps({"mcpServers": {"user_server": {"command": "x", "args": []}}}),
encoding="utf-8",
)
bridge_dir = tmp_path / "bridge"
path = bridge.write_kiro_workspace_mcp_config(workspace, bridge_dir)
assert path == workspace / ".kiro" / "settings" / "mcp.json"
written = json.loads(path.read_text())
assert set(written["mcpServers"]) == {"user_server", "omnigent"}
assert "serve-mcp" in written["mcpServers"]["omnigent"]["args"]
# serve-mcp's token file is written alongside.
assert (bridge_dir / "bridge.json").exists()
def test_inject_model_command_switches_and_confirms(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``/model <id>`` is typed literally and confirmed via kiro's success line."""
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_TYPE_SETTLE_S", 0.0)
bridge_dir = tmp_path / "bridge"
marker_pane = "Model changed to claude-haiku-4.5 (saved as default)\n" + _READY_PANE
calls = _install_fake_tmux(monkeypatch, pane_outputs=[_READY_PANE, marker_pane])
write_tmux_target(bridge_dir, socket_path=Path("/tmp/tmux.sock"), tmux_target="main")
bridge.inject_model_command(bridge_dir, model="claude-haiku-4.5", timeout_s=0.1)
sent = [call[-1] for call in calls if "send-keys" in call]
# Clear the draft (C-a/C-k), send the literal slash command, then Enter.
assert sent == ["C-a", "C-k", "/model claude-haiku-4.5", "Enter"]
def test_inject_model_command_raises_when_switch_not_confirmed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A missing ``Model changed to <id>`` line fails loudly (bad/unavailable id)."""
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
monkeypatch.setattr(bridge, "_TYPE_SETTLE_S", 0.0)
monkeypatch.setattr(bridge, "_MODEL_CONFIRM_TIMEOUT_S", 0.05)
bridge_dir = tmp_path / "bridge"
# Pane stays at the input prompt and never shows the confirmation line.
_install_fake_tmux(monkeypatch, pane_outputs=[_READY_PANE])
write_tmux_target(bridge_dir, socket_path=Path("/tmp/tmux.sock"), tmux_target="main")
with pytest.raises(RuntimeError, match="did not confirm the model switch"):
bridge.inject_model_command(bridge_dir, model="bogus-model", timeout_s=0.1)