feat: introduce runner protocol and normalized event model (#7)
This commit is contained in:
+170
-131
@@ -1,146 +1,116 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from types import SimpleNamespace
|
||||
|
||||
from takopi.exec_render import ExecProgressRenderer, render_event_cli, render_markdown
|
||||
|
||||
|
||||
def _loads(lines: str) -> list[dict]:
|
||||
return [json.loads(line) for line in lines.strip().splitlines() if line.strip()]
|
||||
|
||||
|
||||
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "codex.jsonl"
|
||||
ALL_FORMATS_FIXTURE_PATH = (
|
||||
Path(__file__).resolve().parent / "fixtures" / "codex_exec_json_all_formats.jsonl"
|
||||
)
|
||||
ALL_FORMATS_GOLDEN_PATH = (
|
||||
Path(__file__).resolve().parent / "fixtures" / "codex_exec_json_all_formats.txt"
|
||||
from takopi.markdown import render_markdown
|
||||
from takopi.model import TakopiEvent
|
||||
from takopi.render import ExecProgressRenderer, render_event_cli
|
||||
from tests.factories import (
|
||||
action_completed,
|
||||
action_started,
|
||||
session_started,
|
||||
)
|
||||
|
||||
SAMPLE_STREAM = """
|
||||
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\\nAGENTS.md\\nCHANGELOG.md\\ncliff.toml\\ncodex-cli\\ncodex-rs\\ndocs\\nexamples\\nflake.lock\\nflake.nix\\nLICENSE\\nnode_modules\\nNOTICE\\npackage.json\\npnpm-lock.yaml\\npnpm-workspace.yaml\\nPNPM.md\\nREADME.md\\nscripts\\nsdk\\ntmp\\n","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}}
|
||||
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Yep — there’s a `README.md` in the repository root."}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}
|
||||
"""
|
||||
|
||||
def _format_resume(token) -> str:
|
||||
return f"`codex resume {token.value}`"
|
||||
|
||||
|
||||
def test_render_event_cli_sample_stream() -> None:
|
||||
last_turn = None
|
||||
SAMPLE_EVENTS: list[TakopiEvent] = [
|
||||
session_started("codex", "0199a213-81c0-7800-8aa1-bbab2a035a53", title="Codex"),
|
||||
action_started("a-1", "command", "bash -lc ls"),
|
||||
action_completed(
|
||||
"a-1",
|
||||
"command",
|
||||
"bash -lc ls",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
),
|
||||
action_completed("a-2", "note", "Checking repository root for README", ok=True),
|
||||
]
|
||||
|
||||
|
||||
def test_render_event_cli_sample_events() -> None:
|
||||
out: list[str] = []
|
||||
for evt in _loads(SAMPLE_STREAM):
|
||||
last_turn, lines = render_event_cli(evt, last_turn)
|
||||
out.extend(lines)
|
||||
for evt in SAMPLE_EVENTS:
|
||||
out.extend(render_event_cli(evt))
|
||||
|
||||
assert out == [
|
||||
"thread started",
|
||||
"turn started",
|
||||
"0. **Searching for README files**",
|
||||
"1. ▸ `bash -lc ls`",
|
||||
"1. ✓ `bash -lc ls`",
|
||||
"2. **Checking repository root for README**",
|
||||
"assistant:",
|
||||
" Yep — there’s a `README.md` in the repository root.",
|
||||
"turn completed",
|
||||
"codex",
|
||||
"▸ `bash -lc ls`",
|
||||
"✓ `bash -lc ls`",
|
||||
"✓ Checking repository root for README",
|
||||
]
|
||||
|
||||
|
||||
def test_render_event_cli_real_run_fixture() -> None:
|
||||
events = _loads(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
last_turn = None
|
||||
def test_render_event_cli_handles_action_kinds() -> None:
|
||||
events: list[TakopiEvent] = [
|
||||
action_completed(
|
||||
"c-1", "command", "pytest -q", ok=False, detail={"exit_code": 1}
|
||||
),
|
||||
action_completed(
|
||||
"s-1",
|
||||
"web_search",
|
||||
"python jsonlines parser handle unknown fields",
|
||||
ok=True,
|
||||
),
|
||||
action_completed("t-1", "tool", "github.search_issues", ok=True),
|
||||
action_completed(
|
||||
"f-1",
|
||||
"file_change",
|
||||
"2 files",
|
||||
ok=True,
|
||||
detail={
|
||||
"changes": [
|
||||
{"path": "README.md", "kind": "add"},
|
||||
{"path": "src/compute_answer.py", "kind": "update"},
|
||||
]
|
||||
},
|
||||
),
|
||||
action_completed("n-1", "note", "stream error", ok=False),
|
||||
]
|
||||
|
||||
out: list[str] = []
|
||||
for evt in events:
|
||||
last_turn, lines = render_event_cli(evt, last_turn)
|
||||
out.extend(lines)
|
||||
out.extend(render_event_cli(evt))
|
||||
|
||||
print("\n".join(out))
|
||||
|
||||
assert out[0] == "thread started"
|
||||
assert "turn started" in out
|
||||
assert any(line.startswith("0. ▸ `") for line in out)
|
||||
assert any(line.startswith("0. ✓ `") for line in out)
|
||||
assert "assistant:" in out
|
||||
assert any("takopi" in line for line in out)
|
||||
assert out[-1] == "turn completed"
|
||||
|
||||
|
||||
def test_render_event_cli_all_formats_fixture() -> None:
|
||||
events = _loads(ALL_FORMATS_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
last_turn = None
|
||||
out: list[str] = []
|
||||
for evt in events:
|
||||
last_turn, lines = render_event_cli(evt, last_turn)
|
||||
out.extend(lines)
|
||||
|
||||
assert "thread started" in out
|
||||
assert "turn started" in out
|
||||
assert any(line.startswith("stream error:") for line in out)
|
||||
assert any(line.startswith("4. ▸ `pytest -q`") for line in out)
|
||||
assert any("✗ `pytest -q` (exit 1)" in line for line in out)
|
||||
assert any(line.startswith("✗ `pytest -q` (exit 1)") for line in out)
|
||||
assert any(
|
||||
"searched: python jsonlines parser handle unknown fields" in line
|
||||
for line in out
|
||||
)
|
||||
assert any("tool: github.search_issues" in line for line in out)
|
||||
assert any("updated `src/compute_answer.py`" in line for line in out)
|
||||
assert any(
|
||||
line.startswith(
|
||||
"turn failed: Aborted: required dependency `npm` is missing; cannot continue."
|
||||
)
|
||||
for line in out
|
||||
)
|
||||
assert "assistant:" in out
|
||||
assert any("Legacy schema example" in line for line in out)
|
||||
|
||||
|
||||
def test_render_event_cli_all_formats_golden() -> None:
|
||||
events = _loads(ALL_FORMATS_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
last_turn = None
|
||||
out: list[str] = []
|
||||
for evt in events:
|
||||
last_turn, lines = render_event_cli(evt, last_turn)
|
||||
out.extend(lines)
|
||||
|
||||
print("\n".join(out))
|
||||
|
||||
expected = ALL_FORMATS_GOLDEN_PATH.read_text(encoding="utf-8").rstrip("\n")
|
||||
assert "\n".join(out) == expected
|
||||
assert any("files: +README.md, ~src/compute_answer.py" in line for line in out)
|
||||
assert any(line.startswith("✗ stream error") for line in out)
|
||||
|
||||
|
||||
def test_progress_renderer_renders_progress_and_final() -> None:
|
||||
r = ExecProgressRenderer(max_actions=5)
|
||||
for evt in _loads(SAMPLE_STREAM):
|
||||
r = ExecProgressRenderer(max_actions=5, resume_formatter=_format_resume)
|
||||
for evt in SAMPLE_EVENTS:
|
||||
r.note_event(evt)
|
||||
|
||||
progress = r.render_progress(3.0)
|
||||
assert progress.startswith("working · 3s · step 3")
|
||||
assert "1\\. ✓ `bash -lc ls`" in progress
|
||||
assert "resume: `0199a213-81c0-7800-8aa1-bbab2a035a53`" in progress
|
||||
assert progress.startswith("working · 3s · step 2")
|
||||
assert "✓ `bash -lc ls`" in progress
|
||||
assert "`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`" in progress
|
||||
|
||||
final = r.render_final(3.0, "answer", status="done")
|
||||
assert final.startswith("done · 3s · step 3")
|
||||
assert "running:" not in final
|
||||
assert "ran:" not in final
|
||||
assert final.startswith("done · 3s · step 2")
|
||||
assert "answer" in final
|
||||
assert final.rstrip().endswith("resume: `0199a213-81c0-7800-8aa1-bbab2a035a53`")
|
||||
assert final.rstrip().endswith(
|
||||
"`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`"
|
||||
)
|
||||
|
||||
|
||||
def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None:
|
||||
r = ExecProgressRenderer(max_actions=3, command_width=20)
|
||||
events = [
|
||||
{
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": f"item_{i}",
|
||||
"type": "command_execution",
|
||||
"command": f"echo {i}",
|
||||
"exit_code": 0,
|
||||
"status": "completed",
|
||||
},
|
||||
}
|
||||
action_completed(
|
||||
f"item_{i}",
|
||||
"command",
|
||||
f"echo {i}",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
|
||||
@@ -148,33 +118,102 @@ def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None:
|
||||
assert r.note_event(evt) is True
|
||||
|
||||
assert len(r.recent_actions) == 3
|
||||
assert r.recent_actions[0].startswith("3\\. ")
|
||||
assert r.recent_actions[-1].startswith("5\\. ")
|
||||
assert r.note_event({"type": "mystery"}) is False
|
||||
assert "echo 3" in r.recent_actions[0]
|
||||
assert "echo 5" in r.recent_actions[-1]
|
||||
mystery = SimpleNamespace(type="mystery")
|
||||
assert r.note_event(cast(TakopiEvent, mystery)) is False
|
||||
|
||||
|
||||
def test_progress_renderer_preserves_item_ids_in_telegram_text() -> None:
|
||||
def test_progress_renderer_renders_commands_in_markdown() -> None:
|
||||
r = ExecProgressRenderer(max_actions=5, command_width=None)
|
||||
for i in (30, 31, 32):
|
||||
r.note_event(
|
||||
{
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": f"item_{i}",
|
||||
"type": "command_execution",
|
||||
"command": f"echo {i}",
|
||||
"exit_code": 0,
|
||||
"status": "completed",
|
||||
},
|
||||
}
|
||||
action_completed(
|
||||
f"item_{i}",
|
||||
"command",
|
||||
f"echo {i}",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
)
|
||||
)
|
||||
|
||||
md = r.render_progress(0.0)
|
||||
assert "30\\." in md
|
||||
assert "31\\." in md
|
||||
assert "32\\." in md
|
||||
|
||||
text, _ = render_markdown(md)
|
||||
assert "30. ✓ echo 30" in text
|
||||
assert "31. ✓ echo 31" in text
|
||||
assert "32. ✓ echo 32" in text
|
||||
assert "✓ echo 30" in text
|
||||
assert "✓ echo 31" in text
|
||||
assert "✓ echo 32" in text
|
||||
|
||||
|
||||
def test_progress_renderer_handles_duplicate_action_ids() -> None:
|
||||
r = ExecProgressRenderer(max_actions=5)
|
||||
events = [
|
||||
action_started("dup", "command", "echo first"),
|
||||
action_completed(
|
||||
"dup",
|
||||
"command",
|
||||
"echo first",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
),
|
||||
action_started("dup", "command", "echo second"),
|
||||
action_completed(
|
||||
"dup",
|
||||
"command",
|
||||
"echo second",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
),
|
||||
]
|
||||
|
||||
for evt in events:
|
||||
assert r.note_event(evt) is True
|
||||
|
||||
assert len(r.recent_actions) == 2
|
||||
assert r.recent_actions[0].startswith("✓ ")
|
||||
assert "echo first" in r.recent_actions[0]
|
||||
assert r.recent_actions[1].startswith("✓ ")
|
||||
assert "echo second" in r.recent_actions[1]
|
||||
|
||||
|
||||
def test_progress_renderer_collapses_action_updates() -> None:
|
||||
r = ExecProgressRenderer(max_actions=5)
|
||||
events = [
|
||||
action_started("a-1", "command", "echo one"),
|
||||
action_started("a-1", "command", "echo two"),
|
||||
action_completed(
|
||||
"a-1",
|
||||
"command",
|
||||
"echo two",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
),
|
||||
]
|
||||
|
||||
for evt in events:
|
||||
assert r.note_event(evt) is True
|
||||
|
||||
assert r.action_count == 1
|
||||
assert len(r.recent_actions) == 1
|
||||
assert r.recent_actions[0].startswith("✓ ")
|
||||
assert "echo two" in r.recent_actions[0]
|
||||
|
||||
|
||||
def test_progress_renderer_deterministic_output() -> None:
|
||||
events = [
|
||||
action_started("a-1", "command", "echo ok"),
|
||||
action_completed(
|
||||
"a-1",
|
||||
"command",
|
||||
"echo ok",
|
||||
ok=True,
|
||||
detail={"exit_code": 0},
|
||||
),
|
||||
]
|
||||
r1 = ExecProgressRenderer(max_actions=5)
|
||||
r2 = ExecProgressRenderer(max_actions=5)
|
||||
|
||||
for evt in events:
|
||||
r1.note_event(evt)
|
||||
r2.note_event(evt)
|
||||
|
||||
assert r1.render_progress(1.0) == r2.render_progress(1.0)
|
||||
|
||||
Reference in New Issue
Block a user