Summary
In the interactive conversation loop — the headline documented feature — the agent's reply is printed and then immediately erased on any real (TTY) terminal. Each turn the user sees only their own prompt and the timing line; the reply is gone. A single-line reply (the common case) vanishes entirely; a multi-line reply loses its last line.
This does not show up in single-shot/piped/--quiet runs (no spinner there), which is likely why the test suite and the README's --demo "…" one-liner look fine — those exercise the scriptable path, not the interactive TTY path a real user takes.
Reproduces on a clean pip install langstage-cli (0.6.18, langstage-core 1.0.19) with the demo agent and the README's own "Creating Your Own Agent" example.
Root cause
In run_single_turn_agui() (langstage_cli/cli.py), the spinner is stopped twice per turn:
spinner = Spinner("Thinking")
spinner.start()
try:
async for chunk in agui_stream_updates(...):
if first_chunk:
spinner.stop() # (1) clears the "Thinking…" line — correct
first_chunk = False
print_chunk(chunk, ...) # prints "⏺ <reply>" with end="" (NO trailing newline)
finally:
if spinner:
spinner.stop() # (2) runs AGAIN after the reply — BUG
Spinner.stop() ends with:
print("\r\033[2K", end="", flush=True) # CR + "erase entire line"
Because the reply is printed with end="" (cursor stays on the reply's last line) and never terminated with a newline before the finally, the second spinner.stop() emits \r\033[2K which erases the line the reply is sitting on. print_timing() then writes the timing where the reply used to be.
Reproduction
python -m venv v && ./v/bin/pip install langstage-cli # 0.6.18
# Drive a real PTY (isatty True) and render the output through a VT emulator:
./v/bin/pip install pyte
repro.py:
import os, pty, select, time, sys, pyte
pid, fd = pty.fork()
if pid == 0:
os.execvpe("langstage-cli", ["langstage-cli", "--demo"], dict(os.environ))
time.sleep(1.2)
os.write(fd, b"remember the number 7\n")
time.sleep(1.0)
os.write(fd, b"/quit\n")
out = b""; end = time.time() + 5
while time.time() < end:
r,_,_ = select.select([fd], [], [], 0.4)
if r:
try: d = os.read(fd, 4096)
except OSError: break
if not d: break
out += d
screen = pyte.Screen(100, 40); pyte.Stream(screen).feed(out.decode("utf-8","replace"))
print("\n".join(l for l in screen.display if l.strip()))
Expected vs actual
Expected (what the raw byte stream contains, before the clear):
❯ remember the number 7
⏺ (demo agent) You said: remember the number 7
85ms
Actual (what a terminal renders — reply erased):
❯ remember the number 7
85ms
Multi-line reply (AIMessage("LINE-ONE\nLINE-TWO\nLINE-THREE-LAST")) — only the last line is erased, confirming it is a single current-line \033[2K:
❯ hi
⏺ LINE-ONE
LINE-TWO
81ms <- "LINE-THREE-LAST" erased
Same result with the README custom agent (-a my_agent.py:graph) and with -v/--verbose.
Causation proof
Guarding Spinner.stop() so the line is cleared only once makes the reply survive:
import langstage_cli.cli as cli
_orig = cli.Spinner.stop
def stop_once(self):
if getattr(self, "_stopped", False): return
self._stopped = True; _orig(self)
cli.Spinner.stop = stop_once
→ ❯ Hello there / ⏺ You said: Hello there / 81ms (reply now visible).
Suggested fix
Any one of:
- Make
Spinner.stop() idempotent (guard with a self._stopped flag), so the finally re-stop is a no-op; or
- In
run_single_turn_agui, only call spinner.stop() in finally when it wasn't already stopped on the first chunk (e.g. track spinner_stopped); or
- Emit a terminating newline after the streamed reply (before
print_timing) so a subsequent line-clear can't land on the reply line.
Environment
- langstage-cli 0.6.18 (PyPI), langstage-core 1.0.19, langgraph 1.2.9, click 8.4.2
- Python 3.11, Linux; reproduced via a real PTY rendered through
pyte (accurate VT model). \033[2K = "erase entire line" is universal across ANSI terminals.
Severity
Blocker — the primary documented workflow (the interactive conversation loop, langstage-cli --demo / -a my_agent.py:graph) shows no agent output on a real terminal.
Summary
In the interactive conversation loop — the headline documented feature — the agent's reply is printed and then immediately erased on any real (TTY) terminal. Each turn the user sees only their own prompt and the timing line; the reply is gone. A single-line reply (the common case) vanishes entirely; a multi-line reply loses its last line.
This does not show up in single-shot/piped/
--quietruns (no spinner there), which is likely why the test suite and the README's--demo "…"one-liner look fine — those exercise the scriptable path, not the interactive TTY path a real user takes.Reproduces on a clean
pip install langstage-cli(0.6.18, langstage-core 1.0.19) with the demo agent and the README's own "Creating Your Own Agent" example.Root cause
In
run_single_turn_agui()(langstage_cli/cli.py), the spinner is stopped twice per turn:Spinner.stop()ends with:Because the reply is printed with
end=""(cursor stays on the reply's last line) and never terminated with a newline before thefinally, the secondspinner.stop()emits\r\033[2Kwhich erases the line the reply is sitting on.print_timing()then writes the timing where the reply used to be.Reproduction
repro.py:Expected vs actual
Expected (what the raw byte stream contains, before the clear):
Actual (what a terminal renders — reply erased):
Multi-line reply (
AIMessage("LINE-ONE\nLINE-TWO\nLINE-THREE-LAST")) — only the last line is erased, confirming it is a single current-line\033[2K:Same result with the README custom agent (
-a my_agent.py:graph) and with-v/--verbose.Causation proof
Guarding
Spinner.stop()so the line is cleared only once makes the reply survive:→
❯ Hello there/⏺ You said: Hello there/81ms(reply now visible).Suggested fix
Any one of:
Spinner.stop()idempotent (guard with aself._stoppedflag), so thefinallyre-stop is a no-op; orrun_single_turn_agui, only callspinner.stop()infinallywhen it wasn't already stopped on the first chunk (e.g. trackspinner_stopped); orprint_timing) so a subsequent line-clear can't land on the reply line.Environment
pyte(accurate VT model).\033[2K= "erase entire line" is universal across ANSI terminals.Severity
Blocker — the primary documented workflow (the interactive conversation loop,
langstage-cli --demo/-a my_agent.py:graph) shows no agent output on a real terminal.