Files
omnigent-ai--omnigent/tests/test_kiro_native_session_forwarder.py
2026-07-13 13:12:00 +08:00

834 lines
29 KiB
Python

"""Tests for mirroring Kiro's persisted CLI session into Omnigent."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
import httpx
import pytest
import omnigent.kiro_native_session_forwarder as forwarder
def _write_kiro_session(
root: Path,
*,
session_id: str,
cwd: Path,
created_at: str = "2026-06-21T01:39:34.528139806Z",
updated_at: str = "2026-06-21T01:40:41.838294036Z",
lines: list[dict[str, Any]] | None = None,
user_turn_metadatas: list[dict[str, Any]] | None = None,
model_id: str | None = None,
) -> Path:
"""Create a minimal Kiro CLI session metadata + JSONL fixture.
Pass ``user_turn_metadatas`` / ``model_id`` to populate the credit-metering
fields the ``.json`` snapshot carries (``session_state.conversation_metadata
.user_turn_metadatas`` and ``session_state.rts_model_state.model_info``).
"""
root.mkdir(parents=True, exist_ok=True)
metadata: dict[str, Any] = {
"session_id": session_id,
"cwd": str(cwd),
"created_at": created_at,
"updated_at": updated_at,
"title": "hello",
}
if user_turn_metadatas is not None or model_id is not None:
session_state: dict[str, Any] = {}
if user_turn_metadatas is not None:
session_state["conversation_metadata"] = {"user_turn_metadatas": user_turn_metadatas}
if model_id is not None:
session_state["rts_model_state"] = {"model_info": {"model_id": model_id}}
metadata["session_state"] = session_state
(root / f"{session_id}.json").write_text(json.dumps(metadata), encoding="utf-8")
jsonl_path = root / f"{session_id}.jsonl"
jsonl_path.write_text(
"\n".join(json.dumps(line) for line in (lines or [])) + "\n",
encoding="utf-8",
)
return jsonl_path
def test_discover_kiro_session_jsonl_filters_by_workspace_and_launch_time(
tmp_path: Path,
) -> None:
"""Discovery binds the unique qualifying Kiro session for the workspace.
The wrong-workspace and below-floor sessions are filtered out, leaving
exactly one candidate, which is bound.
"""
sessions_dir = tmp_path / "sessions" / "cli"
workspace = tmp_path / "repo"
other = tmp_path / "other"
workspace.mkdir()
other.mkdir()
_write_kiro_session(
sessions_dir,
session_id="old",
cwd=workspace,
created_at="2026-06-21T01:00:00Z",
updated_at="2026-06-21T01:00:01Z",
)
_write_kiro_session(
sessions_dir,
session_id="wrong-cwd",
cwd=other,
created_at="2026-06-21T01:39:35Z",
updated_at="2026-06-21T01:41:00Z",
)
expected = _write_kiro_session(
sessions_dir,
session_id="current",
cwd=workspace,
created_at="2026-06-21T01:39:35Z",
updated_at="2026-06-21T01:40:00Z",
)
discovered = forwarder._discover_kiro_session_jsonl(
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
sessions_dir=sessions_dir,
)
assert discovered == ("current", expected)
def test_discover_kiro_session_jsonl_returns_none_when_multiple_candidates(
tmp_path: Path,
) -> None:
"""Two concurrent same-workspace sessions are ambiguous → bind nothing.
Each Kiro session is its own JSONL, so two fresh sessions launched in the
same workspace within the discovery window both qualify. Picking newest-by-
``updated_at`` could latch onto the other session's transcript (cross-talk),
so discovery must return ``None`` and retry rather than guess. Regression for
the #1137 'forwarder can bind the wrong session' item.
"""
sessions_dir = tmp_path / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
# Both created after the launch floor, same workspace — indistinguishable.
_write_kiro_session(
sessions_dir,
session_id="session-a",
cwd=workspace,
created_at="2026-06-21T01:39:35Z",
updated_at="2026-06-21T01:40:10Z",
)
_write_kiro_session(
sessions_dir,
session_id="session-b",
cwd=workspace,
created_at="2026-06-21T01:39:36Z",
updated_at="2026-06-21T01:41:00Z", # newest updated_at — the old tie-break
)
discovered = forwarder._discover_kiro_session_jsonl(
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
sessions_dir=sessions_dir,
)
assert discovered is None
def test_discover_kiro_session_jsonl_skips_session_with_unparseable_created_at(
tmp_path: Path,
) -> None:
"""An undateable same-workspace session can't poison the unique-bind count.
A session whose ``created_at`` won't parse can't be confirmed in-window, so it
is dropped rather than counted as a second candidate — otherwise a single
garbage straggler would make discovery permanently ambiguous and bind nothing.
"""
sessions_dir = tmp_path / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="undateable",
cwd=workspace,
created_at="garbage",
updated_at="2026-06-21T01:41:00Z",
)
expected = _write_kiro_session(
sessions_dir,
session_id="current",
cwd=workspace,
created_at="2026-06-21T01:39:35Z",
updated_at="2026-06-21T01:40:10Z",
)
discovered = forwarder._discover_kiro_session_jsonl(
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
sessions_dir=sessions_dir,
)
assert discovered == ("current", expected)
def test_read_new_kiro_messages_returns_user_and_assistant_text(tmp_path: Path) -> None:
"""The JSONL reader mirrors Kiro prompt and assistant message text."""
jsonl_path = tmp_path / "session.jsonl"
jsonl_path.write_text(
"\n".join(
[
json.dumps(
{
"version": "v1",
"kind": "Prompt",
"data": {
"message_id": "user-1",
"content": [{"kind": "text", "data": "hey"}],
},
}
),
json.dumps(
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "assistant-1",
"content": [{"kind": "text", "data": "Hello there"}],
},
}
),
]
)
+ "\n",
encoding="utf-8",
)
messages, byte_offset = forwarder._read_new_kiro_messages(jsonl_path, 0)
assert messages == [
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="hey"),
forwarder._KiroConversationMessage(
message_id="assistant-1", role="assistant", text="Hello there"
),
]
assert byte_offset == jsonl_path.stat().st_size
def test_read_new_kiro_messages_holds_offset_at_partial_trailing_line(tmp_path: Path) -> None:
"""A record still mid-write (no trailing newline) is not skipped.
Kiro appends to the JSONL live, so a poll can catch a partial final line.
The reader must hold the offset at the last complete (newline-terminated)
line so the partial record is re-read once Kiro finishes it — persisting
``handle.tell()`` past the partial line would drop that record for good.
"""
complete = json.dumps(
{
"version": "v1",
"kind": "Prompt",
"data": {"message_id": "user-1", "content": [{"kind": "text", "data": "first"}]},
}
)
partial = json.dumps(
{
"version": "v1",
"kind": "AssistantMessage",
"data": {"message_id": "assistant-1", "content": [{"kind": "text", "data": "second"}]},
}
)
jsonl_path = tmp_path / "session.jsonl"
# Complete line + a partial second line with NO trailing newline.
jsonl_path.write_text(complete + "\n" + partial, encoding="utf-8")
messages, offset = forwarder._read_new_kiro_messages(jsonl_path, 0)
# Only the complete record is delivered; the offset stops at its newline,
# not at EOF (which would skip the partial record once it's finished).
assert messages == [
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="first")
]
assert offset == len((complete + "\n").encode("utf-8"))
assert offset < jsonl_path.stat().st_size
# Kiro finishes the second record; re-reading from the held offset delivers it.
with jsonl_path.open("a", encoding="utf-8") as handle:
handle.write("\n")
messages, offset = forwarder._read_new_kiro_messages(jsonl_path, offset)
assert messages == [
forwarder._KiroConversationMessage(
message_id="assistant-1", role="assistant", text="second"
)
]
assert offset == jsonl_path.stat().st_size
@pytest.mark.asyncio
async def test_forward_kiro_session_posts_conversation_messages(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One forwarder poll posts Kiro assistant messages as external items."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="kiro-session",
cwd=workspace,
lines=[
{
"version": "v1",
"kind": "Prompt",
"data": {"message_id": "user-1", "content": [{"kind": "text", "data": "hey"}]},
},
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "assistant-1",
"content": [{"kind": "text", "data": "Hey!"}],
},
},
],
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
posted: list[tuple[str, str, forwarder._KiroConversationMessage]] = []
external_ids: list[tuple[str, str]] = []
async def _fake_post(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client
posted.append((session_id, agent_name, message))
async def _fake_patch_external_session_id(
client: httpx.AsyncClient,
*,
session_id: str,
external_session_id: str,
) -> None:
del client
external_ids.append((session_id, external_session_id))
async def _cancel_sleep(_seconds: float) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
monkeypatch.setattr(
forwarder,
"_patch_external_session_id",
_fake_patch_external_session_id,
)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
)
assert posted == [
(
"conv_kiro",
"kiro-native-ui",
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="hey"),
),
(
"conv_kiro",
"kiro-native-ui",
forwarder._KiroConversationMessage(
message_id="assistant-1", role="assistant", text="Hey!"
),
),
]
# The forwarder no longer posts session status; running/idle for
# kiro-native is owned by the PTY watcher's emit_status (#1137). See
# test_forward_kiro_session_does_not_post_session_status for the guard.
assert external_ids == [("conv_kiro", "kiro-session")]
def test_read_kiro_cumulative_credits_sums_every_turn(tmp_path: Path) -> None:
"""Cumulative credits = sum of every turn's metering_usage values."""
sessions_dir = tmp_path / "cli"
_write_kiro_session(
sessions_dir,
session_id="k",
cwd=tmp_path,
user_turn_metadatas=[
{
"metering_usage": [
{"value": 0.06, "unit": "credit"},
{"value": 0.03, "unit": "credit"},
],
"input_token_count": 0,
},
{"metering_usage": [{"value": 0.04, "unit": "credit"}], "input_token_count": 0},
],
model_id="auto",
)
total, model = forwarder._read_kiro_cumulative_credits(sessions_dir / "k.json")
assert total == pytest.approx(0.13)
assert model == "auto"
def test_read_kiro_cumulative_credits_none_without_metering(tmp_path: Path) -> None:
"""No metering (or missing file) yields ``(None, None)`` so callers skip."""
sessions_dir = tmp_path / "cli"
_write_kiro_session(sessions_dir, session_id="k", cwd=tmp_path)
assert forwarder._read_kiro_cumulative_credits(sessions_dir / "k.json") == (None, None)
assert forwarder._read_kiro_cumulative_credits(sessions_dir / "missing.json") == (None, None)
@pytest.mark.asyncio
async def test_forward_kiro_session_posts_cumulative_cost_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The forwarder posts kiro's summed credits as cumulative cost, then dedupes."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="kiro-session",
cwd=workspace,
lines=[
{
"version": "v1",
"kind": "AssistantMessage",
"data": {"message_id": "a1", "content": [{"kind": "text", "data": "hi"}]},
},
],
user_turn_metadatas=[
{
"metering_usage": [
{"value": 0.06, "unit": "credit"},
{"value": 0.03, "unit": "credit"},
]
},
{"metering_usage": [{"value": 0.04, "unit": "credit"}]},
],
model_id="auto",
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
costs: list[tuple[str, float, str | None]] = []
async def _fake_post_cost(
client: httpx.AsyncClient,
*,
session_id: str,
cumulative_cost_usd: float,
model: str | None,
) -> None:
del client
costs.append((session_id, cumulative_cost_usd, model))
async def _noop_message(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client
async def _noop_patch(
client: httpx.AsyncClient, *, session_id: str, external_session_id: str
) -> None:
del client
calls = {"n": 0}
async def _cancel_after_two_polls(_seconds: float) -> None:
calls["n"] += 1
if calls["n"] >= 2:
raise asyncio.CancelledError
async def _noop_model(client: httpx.AsyncClient, *, session_id: str, model: str) -> None:
del client
monkeypatch.setattr(forwarder, "_post_session_cost", _fake_post_cost)
monkeypatch.setattr(forwarder, "_post_external_model_change", _noop_model)
monkeypatch.setattr(forwarder, "_post_conversation_message", _noop_message)
monkeypatch.setattr(forwarder, "_patch_external_session_id", _noop_patch)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_after_two_polls)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
)
# Posted once with the summed credits + model; the unchanged second poll
# does not re-post (server treats cumulative_cost_usd as monotonic).
assert costs == [("conv_kiro", pytest.approx(0.13), "auto")]
state = json.loads((tmp_path / "bridge" / "kiro_session_forwarder.json").read_text())
assert state["session_id"] == "kiro-session"
assert state["byte_offset"] > 0
@pytest.mark.asyncio
async def test_forward_kiro_session_prefers_expected_resume_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A resumed Kiro session id is authoritative over discovery and stale state."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="resumed-session",
cwd=workspace,
created_at="2026-06-21T01:00:00Z",
updated_at="2026-06-21T01:05:00Z",
lines=[
{
"version": "v1",
"kind": "Prompt",
"data": {
"message_id": "resumed-user",
"content": [{"kind": "text", "data": ":0"}],
},
},
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "resumed-assistant",
"content": [{"kind": "text", "data": "resumed reply"}],
},
},
],
)
_write_kiro_session(
sessions_dir,
session_id="newer-discovery-session",
cwd=workspace,
created_at="2026-06-21T02:00:00Z",
updated_at="2026-06-21T02:01:00Z",
lines=[
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "wrong-assistant",
"content": [{"kind": "text", "data": "wrong reply"}],
},
}
],
)
bridge_dir = tmp_path / "bridge"
forwarder._write_state(
bridge_dir,
forwarder._ForwardState(session_id="stale-session", byte_offset=0),
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
posted: list[forwarder._KiroConversationMessage] = []
external_ids: list[str] = []
async def _fake_post(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client, session_id, agent_name
posted.append(message)
async def _fake_patch_external_session_id(
client: httpx.AsyncClient,
*,
session_id: str,
external_session_id: str,
) -> None:
del client, session_id
external_ids.append(external_session_id)
async def _cancel_sleep(_seconds: float) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
monkeypatch.setattr(
forwarder,
"_patch_external_session_id",
_fake_patch_external_session_id,
)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=bridge_dir,
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T02:00:00Z"),
expected_session_id="resumed-session",
)
assert posted == [
forwarder._KiroConversationMessage(message_id="resumed-user", role="user", text=":0"),
forwarder._KiroConversationMessage(
message_id="resumed-assistant", role="assistant", text="resumed reply"
),
]
# No session status is posted by the forwarder anymore (#1137).
assert external_ids == ["resumed-session"]
state = json.loads((bridge_dir / "kiro_session_forwarder.json").read_text())
assert state["session_id"] == "resumed-session"
assert state["byte_offset"] > 0
@pytest.mark.asyncio
async def test_forward_kiro_session_waits_for_expected_resume_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When resuming, do not fall back to a different Kiro session id."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="wrong-session",
cwd=workspace,
created_at="2026-06-21T02:00:00Z",
updated_at="2026-06-21T02:01:00Z",
lines=[
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "wrong-assistant",
"content": [{"kind": "text", "data": "wrong reply"}],
},
}
],
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
posted: list[forwarder._KiroConversationMessage] = []
external_ids: list[str] = []
async def _fake_post(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client, session_id, agent_name
posted.append(message)
async def _fake_patch_external_session_id(
client: httpx.AsyncClient,
*,
session_id: str,
external_session_id: str,
) -> None:
del client, session_id
external_ids.append(external_session_id)
async def _cancel_sleep(_seconds: float) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
monkeypatch.setattr(
forwarder,
"_patch_external_session_id",
_fake_patch_external_session_id,
)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T02:00:00Z"),
expected_session_id="missing-resume-session",
)
assert posted == []
assert external_ids == []
@pytest.mark.asyncio
async def test_forward_kiro_session_does_not_post_session_status(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression for #1137: the forwarder mirrors the transcript but must NOT
post ``external_session_status``. Running/idle for kiro-native is owned by
the PTY watcher's ``emit_status`` (runner/resource_registry.py); posting it
here too double-sourced the session status. Captures real HTTP via a mock
transport so it guards the behaviour however status might be sent."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="kiro-session",
cwd=workspace,
lines=[
{
"version": "v1",
"kind": "Prompt",
"data": {"message_id": "user-1", "content": [{"kind": "text", "data": "hey"}]},
},
{
"version": "v1",
"kind": "AssistantMessage",
"data": {
"message_id": "assistant-1",
"content": [{"kind": "text", "data": "Hey!"}],
},
},
],
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
posted_event_types: list[str] = []
def _handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and request.url.path.endswith("/events"):
posted_event_types.append(json.loads(request.content)["type"])
return httpx.Response(200, json={})
real_async_client = forwarder.httpx.AsyncClient
def _client_factory(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs.pop("transport", None)
return real_async_client(*args, transport=httpx.MockTransport(_handler), **kwargs)
monkeypatch.setattr(forwarder.httpx, "AsyncClient", _client_factory)
async def _cancel_sleep(_seconds: float) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
)
# The transcript is still mirrored…
assert "external_conversation_item" in posted_event_types
# …but the forwarder posts no session status (the PTY watcher owns it).
assert "external_session_status" not in posted_event_types
def test_read_kiro_current_model_reads_without_metering(tmp_path: Path) -> None:
"""The model id is read from rts_model_state even before any turn/metering."""
sessions_dir = tmp_path / "cli"
_write_kiro_session(sessions_dir, session_id="k", cwd=tmp_path, model_id="claude-haiku-4.5")
assert forwarder._read_kiro_current_model(sessions_dir / "k.json") == "claude-haiku-4.5"
# Missing file and a session with no model state both yield None.
assert forwarder._read_kiro_current_model(sessions_dir / "missing.json") is None
_write_kiro_session(sessions_dir, session_id="bare", cwd=tmp_path)
assert forwarder._read_kiro_current_model(sessions_dir / "bare.json") is None
@pytest.mark.asyncio
async def test_forward_kiro_session_mirrors_current_model_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The forwarder mirrors kiro's current model via external_model_change, deduped."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="kiro-session",
cwd=workspace,
lines=[
{
"version": "v1",
"kind": "AssistantMessage",
"data": {"message_id": "a1", "content": [{"kind": "text", "data": "hi"}]},
},
],
# No metering: proves the model mirror fires at launch, before a turn's cost.
model_id="claude-haiku-4.5",
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
models: list[tuple[str, str]] = []
async def _fake_post_model(client: httpx.AsyncClient, *, session_id: str, model: str) -> None:
del client
models.append((session_id, model))
async def _noop_message(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client
async def _noop_patch(
client: httpx.AsyncClient, *, session_id: str, external_session_id: str
) -> None:
del client
calls = {"n": 0}
async def _cancel_after_two_polls(_seconds: float) -> None:
calls["n"] += 1
if calls["n"] >= 2:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder, "_post_external_model_change", _fake_post_model)
monkeypatch.setattr(forwarder, "_post_conversation_message", _noop_message)
monkeypatch.setattr(forwarder, "_patch_external_session_id", _noop_patch)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_after_two_polls)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
)
# Mirrored once; the unchanged second poll does not re-post.
assert models == [("conv_kiro", "claude-haiku-4.5")]