Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/lecode/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ class LlmResponse:
cost_usd: float
#: Characters sent in this call's prompt — calibrates live token estimates.
prompt_chars: int = 0
#: Model-call seconds, including latency/retries but excluding tool execution.
elapsed_s: float = 0.0


@dataclass(frozen=True)
Expand Down Expand Up @@ -320,7 +322,9 @@ async def run(
self._partial = None
prompt_chars = _prompt_chars(history)
await self._emit(on_event, LlmCall(model=self.model, turn=turns + 1))
call_started_at = time.monotonic()
completed = await self._stream_turn(history, on_event)
call_elapsed_s = time.monotonic() - call_started_at
turns += 1

in_tok, out_tok, cost = self._turn_cost(completed)
Expand All @@ -333,6 +337,7 @@ async def run(
output_tokens=out_tok,
cost_usd=cost,
prompt_chars=prompt_chars,
elapsed_s=call_elapsed_s,
),
)
input_tokens += in_tok
Expand Down
7 changes: 7 additions & 0 deletions src/lecode/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ def switch_session(self, session: Session) -> bool:
self._runtime.ctx.permission_checker = checker
self._last_response = ""
self._reload_history() # also refreshes the statusline usage lines
self._status.timed_output_tokens = 0
self._status.model_elapsed_s = 0.0
self._input_history.rebind(session)
if self._input_area is not None:
self._input_area.history = self._input_history
Expand Down Expand Up @@ -1679,6 +1681,9 @@ def _on_event(self, event: Any) -> None:
self._feed.llm_call(event.model, event.turn)
self._activity("thinking")
elif isinstance(event, LlmResponse):
if event.elapsed_s > 0 and event.output_tokens > 0:
self._status.timed_output_tokens += event.output_tokens
self._status.model_elapsed_s += event.elapsed_s
if event.input_tokens > 0 and event.prompt_chars > 0:
# Calibrate the live estimate: EMA of chars/token, clamped.
ratio = min(max(event.prompt_chars / event.input_tokens, 2.0), 8.0)
Expand All @@ -1689,6 +1694,7 @@ def _on_event(self, event: Any) -> None:
event.input_tokens,
event.output_tokens,
event.cost_usd,
elapsed_s=event.elapsed_s,
)
elif isinstance(event, Done):
self._feed.stream_end()
Expand Down Expand Up @@ -1730,6 +1736,7 @@ def _on_child_event(self, agent: str, event: Any) -> None:
event.input_tokens,
event.output_tokens,
event.cost_usd,
elapsed_s=event.elapsed_s,
)
elif isinstance(event, Done):
self._feed.info(f"{agent} finished ({event.stop_reason}, {event.turns} turn(s))")
Expand Down
12 changes: 10 additions & 2 deletions src/lecode/tui/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,24 @@ def llm_call(self, model: str, turn: int) -> None:
)

def llm_response(
self, model: str, turn: int, input_tokens: int, output_tokens: int, cost_usd: float
self,
model: str,
turn: int,
input_tokens: int,
output_tokens: int,
cost_usd: float,
elapsed_s: float = 0.0,
) -> None:
"""Log one finished LLM call: ``← model (round N) · ↑in · ↓out · $cost``."""
"""Log per-call usage and output tok/s over model-call time when available."""
# The streamed answer text has no trailing newline yet — close it first.
self._flush_stream()
line = (
f"[{self._stamp()}] ← {model} (round {turn})"
f" · ↑{human_tokens(input_tokens)} in · ↓{human_tokens(output_tokens)} out"
f" · {format_cost(cost_usd)}"
)
if elapsed_s > 0 and output_tokens > 0:
line += f" · {output_tokens / elapsed_s:.1f} tok/s"
self._console.print(Text(line, style=self._theme.muted))

def tool_call(self, name: str, args_preview: str) -> None:
Expand Down
9 changes: 9 additions & 0 deletions src/lecode/tui/statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class StatusState:
context_window: int = 200_000
input_tokens: int = 0
output_tokens: int = 0
#: Completed responses measured in this session since launch.
timed_output_tokens: int = 0
model_elapsed_s: float = 0.0
cost_usd: float = 0.0
state: StatusLineState = StatusLineState.IDLE
queued: int = 0
Expand Down Expand Up @@ -170,6 +173,12 @@ def labelled(line: Text, label: str, value: str, style: str, *, first: bool = Fa
labelled(line3, "agent", state.agent, theme.accent)
labelled(line3, "in", human_tokens(state.input_tokens), theme.muted)
labelled(line3, "out", human_tokens(state.output_tokens), theme.muted)
speed = (
f"{state.timed_output_tokens / state.model_elapsed_s:.1f}"
if state.model_elapsed_s > 0
else "—"
)
labelled(line3, "avg tok/s", speed, theme.muted)
line3.append_text(sep.copy())
line3.append(state_seg, style=getattr(theme, state_color))

Expand Down
34 changes: 34 additions & 0 deletions tests/test_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
from types import SimpleNamespace

import pytest
from tests.fakes import FakeProvider, sample_catalog
Expand Down Expand Up @@ -110,6 +111,39 @@ async def test_llm_call_event_per_round(tool_ctx):
assert all(r.cost_usd >= 0 for r in responses)


async def test_response_timing_excludes_tools(tool_ctx, monkeypatch):
runner, _ = make_runner(
tool_ctx,
[
{"tool_calls": [{"id": "c1", "name": "echo", "arguments": '{"text": "hi"}'}]},
{"text": "done", "usage": {"input_tokens": 10, "output_tokens": 15}},
],
)
now = 0.0
monkeypatch.setattr("lecode.agent.runner.time", SimpleNamespace(monotonic=lambda: now))
stream_turn = runner._stream_turn

async def timed_stream(history, on_event):
nonlocal now
completed = await stream_turn(history, on_event)
now += 2.0
return completed

monkeypatch.setattr(runner, "_stream_turn", timed_stream)
responses = []

def on_event(event):
nonlocal now
if isinstance(event, ToolResult):
now += 100.0
elif isinstance(event, LlmResponse):
responses.append(event)

result = await runner.run([{"role": "user", "content": "echo hi"}], on_event)
assert [event.elapsed_s for event in responses] == [2.0, 2.0]
assert result.elapsed_s == 104.0


async def test_tool_round_trip(tool_ctx):
script = [
{"tool_calls": [{"id": "c1", "name": "echo", "arguments": '{"text": "hi"}'}]},
Expand Down
11 changes: 11 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,17 @@ async def test_submit_prints_per_answer_stats_line(tmp_path, monkeypatch):
assert "1 round" in rendered


def test_status_average_accumulates_response_tokens_and_time(tmp_path, monkeypatch):
from lecode.agent.runner import LlmResponse

app, _, _ = make_app(tmp_path, monkeypatch, [])
app._on_event(LlmResponse("m", 1, 10, 100, 0.0, elapsed_s=2.0))
app._on_event(LlmResponse("m", 2, 10, 50, 0.0, elapsed_s=4.0))
app._on_event(LlmResponse("m", 3, 10, 90, 0.0))
assert app._status.timed_output_tokens == 150
assert app._status.model_elapsed_s == 6.0


async def test_reasoning_and_tool_events_render(tmp_path, monkeypatch):
script = [
{
Expand Down
14 changes: 14 additions & 0 deletions tests/test_tui_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ def test_llm_response_closes_streamed_line(theme):
assert "← m (round 1)" in lines[1]


@pytest.mark.parametrize(
("output_tokens", "elapsed_s", "speed"),
[(15, 2.0, "7.5 tok/s"), (15, 0.0, None), (15, -1.0, None), (0, 2.0, None)],
)
def test_llm_response_speed(theme, output_tokens, elapsed_s, speed):
feed, out = make_feed(theme)
feed.llm_response("m", 1, 10, output_tokens, 0.001, elapsed_s=elapsed_s)
rendered = out.getvalue()
if speed is None:
assert "tok/s" not in rendered
else:
assert speed in rendered


def test_assistant_text_renders_markdown(theme):
feed, out = make_feed(theme)
feed.assistant_text("# Title\n\nsome **bold** text")
Expand Down
10 changes: 9 additions & 1 deletion tests/test_tui_statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ def test_line2_omits_reasoning_at_baseline(state, theme):

def test_line3_session_agent_tokens_state(state, theme):
line3 = render_statusline(state, theme, width=200).plain.splitlines()[2]
assert line3 == "session: my-session · agent: default · in: 1.2k · out: 0.4k · ready"
assert line3 == (
"session: my-session · agent: default · in: 1.2k · out: 0.4k · avg tok/s: — · ready"
)


def test_average_speed(state, theme):
state.timed_output_tokens = 150
state.model_elapsed_s = 6.0
assert "out: 0.4k · avg tok/s: 25.0" in render_statusline(state, theme, width=200).plain


def test_renders_exactly_three_lines(state, theme):
Expand Down
Loading