Files
strix/tests/test_tui_resume_history.py
T

282 lines
11 KiB
Python

"""Resumed history must attribute only typed messages to the user.
Guidance the system feeds an agent is injected as a user turn, so replayed
history cannot tell it apart from a typed message by role alone. A live run only
shows what the user actually typed; resuming has to match that.
"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING, Any
import pytest
from strix.core.paths import runtime_state_dir
from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView
from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn
if TYPE_CHECKING:
from pathlib import Path
def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None:
"""Persist an agent snapshot plus a session history for hydration to read."""
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "agents.json").write_text(
json.dumps({"statuses": {agent_id: "running"}, "names": {agent_id: "recon"}}),
encoding="utf-8",
)
connection = sqlite3.connect(state_dir / "agents.db")
try:
connection.execute(
"create table agent_messages (id integer primary key, session_id text, "
"message_data text, created_at text)"
)
for index, item in enumerate(items, start=1):
connection.execute(
"insert into agent_messages (id, session_id, message_data, created_at) "
"values (?, ?, ?, ?)",
(index, agent_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
)
connection.commit()
finally:
connection.close()
def _user_messages(view: TuiLiveView) -> list[str]:
return [
str(event["data"]["content"])
for event in view.events
if event.get("type") == "chat" and event["data"].get("role") == "user"
]
def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
# The task the agent was launched with, not a typed message.
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{
"role": "user",
"content": "[Message from system (system) | type=auto_resume | priority=normal]\n"
"Waiting timeout reached.",
},
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
# A stall notice reaches the parent through the coordinator, so it
# arrives wrapped rather than as a bare "[Agent stalled]".
{
"role": "user",
"content": "[Message from recon (a1) | type=stalled | priority=high]\n"
"[Agent stalled] recon (a1) kept ending turns",
},
{
"role": "user",
"content": "Your previous message ended a turn without a tool call. "
"Plain text never ends execution.",
},
{"role": "assistant", "content": "continuing"},
],
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == []
# The agent's own side of the conversation is untouched.
assert [
str(event["data"]["content"])
for event in view.events
if event.get("type") == "chat" and event["data"].get("role") == "assistant"
] == ["starting", "continuing"]
def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{"role": "user", "content": "check the coupon endpoint next"},
{"role": "assistant", "content": "on it"},
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
{"role": "user", "content": "stop testing the admin panel"},
],
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == [
"check the coupon endpoint next",
"stop testing the admin panel",
]
def test_resume_treats_each_agents_first_user_turn_as_its_task(tmp_path: Path) -> None:
"""Subagents get their task the same way, so it is skipped per agent."""
run_dir = tmp_path / "run"
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "agents.json").write_text(
json.dumps(
{
"statuses": {"root": "running", "child": "running"},
"names": {"root": "root", "child": "recon"},
"parent_of": {"child": "root"},
}
),
encoding="utf-8",
)
connection = sqlite3.connect(state_dir / "agents.db")
try:
connection.execute(
"create table agent_messages (id integer primary key, session_id text, "
"message_data text, created_at text)"
)
rows = [
("root", {"role": "user", "content": "\n\nURLs: - https://example.com"}),
("child", {"role": "user", "content": "Audit the login flow."}),
("child", {"role": "user", "content": "also try the password reset"}),
]
for index, (session_id, item) in enumerate(rows, start=1):
connection.execute(
"insert into agent_messages (id, session_id, message_data, created_at) "
"values (?, ?, ?, ?)",
(index, session_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
)
connection.commit()
finally:
connection.close()
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
# Both tasks are skipped; only the follow-up typed at the child remains.
assert _user_messages(view) == ["also try the password reset"]
def test_internal_turn_classifier_matches_every_injected_form() -> None:
for content in (
# Coordinator deliveries, which wrap the stall, terminal and budget notices.
"[Message from recon (a1) | type=information | priority=normal]\nfound it",
"[Message from recon (a1) | type=stalled | priority=high]\n[Agent stalled] recon (a1)",
"[Message from system (system) | type=budget_extended | priority=normal]\n"
"[Budget] extended",
# Budget warnings, the only notices injected without a wrapper.
"[NOTICE] Turn budget: 350/500 used (70%).",
"[URGENT] Scan cost budget: $9.50/$10.00 spent (95%).",
"[CRITICAL] Turn budget: 480/500 used (96%).",
"== Inherited context from parent (background only) ==",
"Your previous message ended a turn without a tool call.",
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
):
assert _is_internal_agent_turn(content), content
def test_internal_turn_classifier_keeps_bracketed_user_text() -> None:
"""A leading bracket is not enough: typed text often starts with one."""
for content in (
'[{"id": 1, "role": "admin"}, {"id": 2}]',
"[link](https://example.com) check this endpoint",
"[URGENT] stop testing the admin panel",
"[2026-01-01 12:00:03] ERROR auth failed - look into this",
"[note] creds are admin:hunter2",
"[Agent] can you check this?",
"[]",
"check the coupon endpoint next",
"Use creds admin:hunter2 for the login form",
"stop",
):
assert not _is_internal_agent_turn(content), content
@pytest.mark.parametrize("view_class", [TuiLiveView, GoTuiLiveView])
def test_user_instruction_opens_the_transcript_when_the_root_agent_appears(
view_class: type[TuiLiveView],
) -> None:
"""A live scan has no root agent yet, so the message waits for it.
Exercised against the projection the Go TUI actually uses as well as the
base one: that subclass overrides upsert_agent without calling back, so a
hook placed there would silently never run.
"""
view = view_class()
view.set_user_instruction("find IDOR in the checkout flow")
assert _user_messages(view) == []
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
assert view.flush_user_instruction() is True
assert _user_messages(view) == ["find IDOR in the checkout flow"]
# Repeated agent syncs and subagents must not repeat it.
view.upsert_agent("cd34", name="recon", parent_id="ab12", status="running")
view.upsert_agent("ab12", status="running")
assert view.flush_user_instruction() is False
assert _user_messages(view) == ["find IDOR in the checkout flow"]
def test_blank_user_instruction_adds_nothing() -> None:
view = TuiLiveView()
view.set_user_instruction(" ")
view.set_user_instruction(None)
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
assert _user_messages(view) == []
def test_replayed_run_opens_with_the_users_instruction(tmp_path: Path) -> None:
"""It comes from the run record and sorts ahead of replayed history."""
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{"role": "user", "content": "also check coupons"},
],
)
(run_dir / "run.json").write_text(
json.dumps(
{
"start_time": "2026-01-01T00:00:00+00:00",
# instruction carries the diff-scope preamble; only the user's own
# text belongs in the transcript.
"instruction": "[diff-scope preamble]\n\naudit the auth flow",
"user_instruction": "audit the auth flow",
}
),
encoding="utf-8",
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == ["audit the auth flow", "also check coupons"]
first = view.events[0]
assert first["data"]["content"] == "audit the auth flow"
# Stamped with the run's start, so ordering by timestamp keeps it first.
assert first["timestamp"] == "2026-01-01T00:00:00+00:00"
def test_replayed_run_without_an_instruction_is_unchanged(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(run_dir, [{"role": "assistant", "content": "starting"}])
(run_dir / "run.json").write_text(
json.dumps({"start_time": "2026-01-01T00:00:00+00:00"}), encoding="utf-8"
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == []