Skip to content

Interactive conversation loop erases every agent reply on a real terminal — the trailing spinner.stop() line-clear wipes the reply, leaving only the timing #84

Description

@dkedar7

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingroutineFiled by the daily power-user dogfooding routine

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions