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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 0.6.21 - 2026-07-19

### Changed
- **`--async-mode`/`--sync-mode` and `--agui` are deprecated — they were no-ops advertised as
features (gh #88).** CHANGELOG 0.6.0 (ADR 0003) said the `--agui`/`--async`/`--stream-mode` flags
were accepted-and-ignored *"for one release"*. `--stream-mode` was duly retired in #62;
`--async-mode`/`--sync-mode` and `--agui` were still shipping **20 patch releases later**, still
inert, and still presented as working controls. Root cause is that ADR 0003 collapsed every turn
onto the single async AG-UI path (`run_single_turn_agui` → `agui_stream.agui_stream_updates` →
`core.agui.iter_chunk_frames`), leaving no second behavior for either flag to select: `use_async`
was resolved, stored in the session context, and read in exactly three places — `/status`,
`/config`, and `--show-config` — **all display-only**, with no `if use_async:` branch anywhere in
`run()`, so `--sync-mode` and `--async-mode` produced byte-identical output; `use_agui` was passed
into `run()` and never read at all. `--agui`'s help text was additionally describing a mechanism
that no longer exists ("instead of the built-in event parser" — since langstage-core 1.0 the AG-UI
adapter *is* the only path, and the `[agui]` extra has been a redundant alias since 0.6.1). Rather
than wire a dead knob up to something invented, both are now retired with the same posture
`--stream-mode` got: hidden from `--help`, dropped from the README (the CLI Options row and the
`langstage.toml` `[ui] async_mode` example), omitted from `--show-config`, removed from `/status`
(the "Mode: async/sync" line, which reported a distinction the runtime does not have) and from the
`/config` key map, no longer resolved from `[ui] async_mode`, and accepted-and-ignored on the CLI
(so an existing `--async-mode` / `--agui` invocation doesn't hard-error) with a one-line
deprecation notice. An existing `langstage.toml` that still sets `[ui] async_mode` keeps loading
untouched — the key is simply ignored, never an error, since a config file that suddenly failed to
resolve would be a worse regression than the dead knob it retires. No streaming behavior changes.
New tests assert the flags are gone from `--help`/`--show-config`/`/status`/`/config`, that each is
still accepted with a notice, and that a TOML carrying the retired key still loads.

## 0.6.20 - 2026-07-18

### Fixed
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ root = "."

[ui]
verbose = true
async_mode = false

[configurable]
# seeds LangGraph RunnableConfig.configurable
Expand All @@ -161,7 +160,6 @@ Options:
-g, --graph-name TEXT Graph variable name (default: "graph")
-f, --file PATH Read message from a file (any extension)
--interactive/--no-interactive Handle interrupts (default: interactive)
--async-mode/--sync-mode Async streaming (default: sync)
-v, --verbose Verbose output
--demo Run with the built-in keyless demo agent
--show-config Print the resolved configuration and exit
Expand Down
31 changes: 14 additions & 17 deletions langstage_cli/agui_stream.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
"""Experimental in-process AG-UI streaming path for the cli (``--agui``).

Instead of parsing ``graph.stream()`` via ``langgraph-stream-parser``'s event
layer, this drives the agent through the official ``ag-ui-langgraph`` adapter
in-process (no web server) and maps AG-UI events onto the cli's existing
``print_chunk`` chunk contract — so the renderer is unchanged.

This is the first step of ADR 0002 (retire the bespoke event layer, converge on
AG-UI). Text + tool calls/results are at parity with the default path (and the
AG-UI path additionally surfaces tool *results*). Interrupts are fully supported:
they DISPLAY as a ``CustomEvent(on_interrupt)`` and RESUME via
``forwarded_props.command.resume`` (ADR 0002 gate 2, resolved).

Requires the ``agui`` extra::

pip install "langstage-cli[agui]"
"""The cli's in-process AG-UI streaming path.

Drives the agent through the official ``ag-ui-langgraph`` adapter in-process (no
web server) and maps AG-UI events onto the cli's ``print_chunk`` chunk contract —
so the renderer is unchanged. Text, tool calls, and tool *results* all render, and
interrupts are fully supported: they DISPLAY as a ``CustomEvent(on_interrupt)`` and
RESUME via ``forwarded_props.command.resume`` (ADR 0002 gate 2, resolved).

ADR 0002 started this as an experimental opt-in behind ``--agui``, alongside a
bespoke event-parser path. ADR 0003 finished the migration: since langstage-core
1.0 this is the ONLY streaming path, there is no parser to fall back to, and the
``agui`` extra is a redundant alias (AG-UI ships as a base dependency, CHANGELOG
0.6.1). The ``--agui`` flag it was named for is deprecated and inert (gh #88).
"""

from typing import Any, AsyncIterator, Dict

_IMPORT_HINT = 'the --agui path needs the agui extra: pip install "langstage-cli[agui]"'
_IMPORT_HINT = 'the AG-UI streaming path needs: pip install "langstage-core[agui]"'


def ensure_agui_available() -> None:
Expand Down
56 changes: 38 additions & 18 deletions langstage_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,10 @@ def _status(msg: str) -> None:
# the diagnostic only advertises knobs that actually do something. The inherited
# HostConfig server keys — it starts no server (host/port/debug are inert) and the
# header box uses the loaded graph's name, not `title` (gh #36) — plus `stream_mode`,
# which has had no effect since the AG-UI streaming migration (gh #62).
_INERT_KEYS = ["host", "port", "debug", "title", "stream_mode"]
# which has had no effect since the AG-UI streaming migration (gh #62), and
# `async_mode`, inert since ADR 0003 collapsed every turn onto the one async AG-UI
# path (gh #88).
_INERT_KEYS = ["host", "port", "debug", "title", "stream_mode", "async_mode"]

# Spinner frames for thinking animation
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
Expand Down Expand Up @@ -1112,13 +1114,13 @@ def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]:
thread_id = config.get("configurable", {}).get("thread_id", "N/A")
agent_name = context.get("agent_name", "Unknown")
verbose = context.get("verbose", False)
use_async = context.get("use_async", False)

print(f"\n{BOLD}{BRIGHT_CYAN}Session Status{RESET}")
print(f"{DIM}{'─' * 30}{RESET}")
print(f" {DIM}Agent:{RESET} {agent_name}")
print(f" {DIM}Thread ID:{RESET} {thread_id[:8]}...")
print(f" {DIM}Mode:{RESET} {'async' if use_async else 'sync'}")
# No "Mode: async/sync" line: since ADR 0003 there is exactly one (async) path,
# so the value was reporting a distinction the runtime does not have (gh #88).
print(f" {DIM}Verbose:{RESET} {'on' if verbose else 'off'}")
print(f" {DIM}CWD:{RESET} {os.getcwd()}")
print()
Expand Down Expand Up @@ -1170,9 +1172,10 @@ def cmd_config(args: str, context: Dict[str, Any]) -> Optional[str]:
configurable = config.get("configurable", {})
if key in configurable:
print(f"\n{CYAN}{key}:{RESET} {configurable[key]}\n")
elif key in ("verbose", "async_mode", "stream_mode"):
ctx_key = "use_async" if key == "async_mode" else key
print(f"\n{CYAN}{key}:{RESET} {context.get(ctx_key)}\n")
elif key in ("verbose", "stream_mode"):
# async_mode is gone from this map: it was a dead knob whose only
# remaining job was to report itself back to the user (gh #88).
print(f"\n{CYAN}{key}:{RESET} {context.get(key)}\n")
else:
print(f"{YELLOW}Unknown config key: {key}{RESET}")
else:
Expand Down Expand Up @@ -1423,8 +1426,6 @@ def run_conversation_loop(
config: Dict[str, Any],
agent_name: str = "Agent",
agent_description: Optional[str] = None,
use_async: bool = False,
use_agui: bool = False,
interactive: bool = True,
verbose: bool = False,
stream_mode: str = "updates",
Expand Down Expand Up @@ -1454,7 +1455,6 @@ def run_conversation_loop(
"graph": graph,
"config": config,
"agent_name": agent_name,
"use_async": use_async,
"interactive": interactive,
"verbose": verbose,
"stream_mode": stream_mode,
Expand Down Expand Up @@ -1613,7 +1613,12 @@ def run_conversation_loop(
"--async-mode/--sync-mode",
"use_async",
default=None,
help="Use async streaming (default: sync)",
# DEPRECATED (gh #88): a no-op since ADR 0003 collapsed every turn onto the single
# async AG-UI path — `--sync-mode` and `--async-mode` produce byte-identical output.
# Kept hidden + accepted so existing invocations don't hard-error; a one-line notice
# fires when either spelling is passed. Same posture as --stream-mode (gh #62).
hidden=True,
help="(deprecated: no effect — every turn streams through the one async path).",
)
@click.option(
"--stream-mode",
Expand Down Expand Up @@ -1641,9 +1646,13 @@ def run_conversation_loop(
"--agui",
is_flag=True,
default=False,
help="[experimental] Stream via the in-process AG-UI adapter instead of the "
"built-in event parser (text, tool calls/results, and interrupts). "
'Requires the agui extra: pip install "langstage-cli[agui]".',
# DEPRECATED (gh #88): never read. It once opted into the experimental in-process
# AG-UI adapter "instead of the built-in event parser"; since langstage-core 1.0
# that adapter is the ONLY streaming path, so there is nothing left to opt into
# (and the [agui] extra is a redundant alias — CHANGELOG 0.6.1). Hidden + accepted
# so existing invocations don't hard-error, with a one-line notice.
hidden=True,
help="(deprecated: no effect — the AG-UI adapter is the only streaming path).",
)
@click.option(
"--show-config",
Expand Down Expand Up @@ -1758,7 +1767,6 @@ def main(
"stream_mode": stream_mode,
# bool flags only override when actually passed; otherwise fall back to
# TOML/env/default.
"async_mode": True if use_async else None,
"verbose": True if verbose else None,
}

Expand All @@ -1770,6 +1778,21 @@ def main(
f"(streaming is uniform since the AG-UI migration).{RESET}"
)

# --async-mode/--sync-mode and --agui are deprecated and inert too (gh #88): ADR
# 0003 collapsed every turn onto the one async AG-UI path, so neither flag has a
# branch left to take. Same posture as --stream-mode above — accepted so existing
# invocations don't hard-error, with one notice each.
if use_async is not None:
_status(
f"{DIM}⏺ Note: --async-mode/--sync-mode is deprecated and has no effect "
f"(every turn streams through the one async path).{RESET}"
)
if agui:
_status(
f"{DIM}⏺ Note: --agui is deprecated and has no effect "
f"(the AG-UI adapter is the only streaming path).{RESET}"
)

if show_config:
# The COMPLETE diagnostic — fields (server/web keys this surface ignores omitted,
# gh #36) + the honored [configurable] table (gh #57/#66) — comes from the one
Expand Down Expand Up @@ -1834,7 +1857,6 @@ def main(
# accepted-and-ignored flag now (env/TOML no longer resolve it), so there is
# nothing to validate; it changes no rendering.
final_stream_mode = cfg.stream_mode
use_async = cfg.async_mode
verbose = cfg.verbose
# Whether a workspace root was explicitly configured (vs the default cwd);
# cli chdirs into it only when it was, matching prior behavior.
Expand Down Expand Up @@ -1917,8 +1939,6 @@ def main(
config=config_dict,
agent_name=agent_name,
agent_description=agent_description,
use_async=use_async,
use_agui=agui,
interactive=interactive,
verbose=verbose,
stream_mode=final_stream_mode,
Expand Down
7 changes: 6 additions & 1 deletion langstage_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,18 @@ class CodeConfig(HostConfig):
stream_mode: str = "auto"
graph_name: str = "graph"
verbose: bool = False
# async_mode is likewise retained as an inert field (default only) so nothing that
# reads the dataclass breaks, but it is DEPRECATED: ADR 0003 collapsed every turn
# onto the single async AG-UI path, so `--async-mode` / `--sync-mode` selected
# between two identical behaviors. It is no longer resolved from `[ui] async_mode`
# and is omitted from `--show-config`. An existing `langstage.toml` that still sets
# the key keeps loading — the key is simply ignored, never an error. (gh #88)
async_mode: bool = False

_ENV: ClassVar[dict] = {}
_TOML: ClassVar[dict] = {
"graph_name": "agent.graph_name",
"verbose": "ui.verbose",
"async_mode": "ui.async_mode",
}

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "langstage-cli"
version = "0.6.20"
version = "0.6.21"
description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
5 changes: 4 additions & 1 deletion tests/test_agui_stream.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Tests for the experimental in-process AG-UI streaming path (--agui, ADR 0002).
"""Tests for the in-process AG-UI streaming path (ADR 0002, ADR 0003).

Started life behind the experimental `--agui` flag; since langstage-core 1.0 this is
the only streaming path and that flag is a deprecated no-op (gh #88).

Skipped unless the agui extra is installed. The dev extra pulls it so CI runs these.
"""
Expand Down
108 changes: 108 additions & 0 deletions tests/test_async_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""--async-mode/--sync-mode and --agui are deprecated and inert (gh #88).

CHANGELOG 0.6.0 (ADR 0003) said `--agui`/`--async`/`--stream-mode` were
accepted-and-ignored "for one release". `--stream-mode` was duly retired in #62;
`--async-mode`/`--sync-mode` and `--agui` were still shipping 20 patch releases
later — still inert, and still advertised as functional in `--help`, the README,
`--show-config`, `/status` ("Mode: async"), and `/config`. Since ADR 0003 collapsed
every turn onto the single async AG-UI path there is no second behavior left for
either flag to select: `--sync-mode` and `--async-mode` produced byte-identical
output, and `use_agui` was never read at all.

They now follow #62's posture exactly: hidden from `--help`, omitted from
`--show-config` and `/status`, no longer resolved from `[ui] async_mode`, and
accepted-and-ignored on the CLI (so existing invocations don't hard-error) with a
one-line deprecation notice.
"""

import re

from click.testing import CliRunner

from langstage_cli.cli import cmd_status, main


def test_async_mode_flag_is_accepted_and_ignored_with_a_notice():
# An existing `--async-mode` invocation must not hard-error; it is accepted,
# ignored, and prints a one-line deprecation notice.
r = CliRunner().invoke(main, ["--demo", "--no-interactive", "--async-mode", "hi"])
assert r.exit_code == 0, r.output
assert "--async-mode/--sync-mode is deprecated" in r.output


def test_sync_mode_flag_is_accepted_and_ignored_with_a_notice():
# The off-spelling of the same flag gets the same treatment.
r = CliRunner().invoke(main, ["--demo", "--no-interactive", "--sync-mode", "hi"])
assert r.exit_code == 0, r.output
assert "--async-mode/--sync-mode is deprecated" in r.output


def test_agui_flag_is_accepted_and_ignored_with_a_notice():
r = CliRunner().invoke(main, ["--demo", "--no-interactive", "--agui", "hi"])
assert r.exit_code == 0, r.output
assert "--agui is deprecated" in r.output


def test_no_notice_when_the_dead_flags_are_not_passed():
# The deprecation notices must be opt-in noise only — a normal run stays clean.
r = CliRunner().invoke(main, ["--demo", "--no-interactive", "hi"])
assert r.exit_code == 0, r.output
assert "deprecated" not in r.output.lower()


def test_dead_flags_are_hidden_from_help():
# The issue's core complaint: the flags were advertised as functional controls.
r = CliRunner().invoke(main, ["--help"])
assert r.exit_code == 0, r.output
for flag in ("--async-mode", "--sync-mode", "--agui"):
assert flag not in r.output, f"{flag} should no longer be advertised in --help"


def test_async_mode_is_omitted_from_show_config():
# gh #88: `--show-config` printed `async_mode = True [override]`, presenting a dead
# knob as an active override. It is no longer part of the diagnostic at all.
with CliRunner().isolated_filesystem(): # no stray toml
r = CliRunner().invoke(main, ["--show-config"])
assert r.exit_code == 0, r.output
assert "async_mode" not in r.output


def test_async_mode_flag_does_not_reappear_in_show_config_as_an_override():
# Even when the (accepted-and-ignored) flag is passed alongside --show-config.
with CliRunner().isolated_filesystem():
r = CliRunner().invoke(main, ["--async-mode", "--show-config"])
assert r.exit_code == 0, r.output
assert not re.search(r"^\s*async_mode\s*=", r.output, re.MULTILINE), r.output


def test_toml_async_mode_is_ignored_not_an_error(tmp_path, monkeypatch):
# A langstage.toml carrying the retired `[ui] async_mode` key — the README used to
# ship exactly this example — must keep loading. The key is ignored; the run and
# the rest of the file are unaffected. A config that suddenly failed to load would
# be a nastier regression than the dead knob being retired.
(tmp_path / "langstage.toml").write_text(
'[ui]\nasync_mode = true\n\n[configurable]\nthread_id = "keeps-working"\n'
)
monkeypatch.chdir(tmp_path)
r = CliRunner().invoke(main, ["--demo", "--no-interactive", "hi"])
assert r.exit_code == 0, r.output
assert "async_mode" not in r.output
assert "error" not in r.output.lower()


def test_status_does_not_report_a_streaming_mode(capsys):
# gh #88: `/status` printed "Mode: async" / "Mode: sync" — a distinction the runtime
# does not have. The line is gone; the rest of the status block is untouched.
cmd_status("", {"config": {"configurable": {"thread_id": "abcdef123456"}}, "verbose": True})
out = capsys.readouterr().out
assert "Mode:" not in out
assert "async" not in out
assert "Agent:" in out and "Verbose:" in out


def test_config_key_lookup_no_longer_knows_async_mode(capsys):
# `/config async_mode` used to echo the dead value back; it is now an unknown key.
from langstage_cli.cli import cmd_config

cmd_config("async_mode", {"config": {}})
assert "Unknown config key: async_mode" in capsys.readouterr().out
4 changes: 4 additions & 0 deletions tests/test_cli_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ def test_help_leads_with_canonical_names():
# ...and the deprecated, no-op stream-mode knob is no longer advertised (gh #62)
assert "LANGSTAGE_STREAM_MODE" not in out
assert "--stream-mode" not in out
# ...nor the equally dead async-mode / agui knobs (gh #88)
assert "--async-mode" not in out
assert "--sync-mode" not in out
assert "--agui" not in out
# ...and the legacy names are no longer presented as the only config vocab
assert "DEEPAGENT_AGENT_SPEC" not in out
Loading
Loading