834 lines
29 KiB
Python
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")]
|