diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bccec8..254b26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b42970b..abbbb9a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,6 @@ root = "." [ui] verbose = true -async_mode = false [configurable] # seeds LangGraph RunnableConfig.configurable @@ -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 diff --git a/langstage_cli/agui_stream.py b/langstage_cli/agui_stream.py index b89495f..6c3fd76 100644 --- a/langstage_cli/agui_stream.py +++ b/langstage_cli/agui_stream.py @@ -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: diff --git a/langstage_cli/cli.py b/langstage_cli/cli.py index 193ffe4..0874389 100644 --- a/langstage_cli/cli.py +++ b/langstage_cli/cli.py @@ -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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -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() @@ -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: @@ -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", @@ -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, @@ -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", @@ -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", @@ -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, } @@ -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 @@ -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. @@ -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, diff --git a/langstage_cli/config.py b/langstage_cli/config.py index 8df620c..1c9b9f6 100644 --- a/langstage_cli/config.py +++ b/langstage_cli/config.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6bd7c3f..2f1efd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_agui_stream.py b/tests/test_agui_stream.py index e4d572b..a9914a2 100644 --- a/tests/test_agui_stream.py +++ b/tests/test_agui_stream.py @@ -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. """ diff --git a/tests/test_async_mode.py b/tests/test_async_mode.py new file mode 100644 index 0000000..d984931 --- /dev/null +++ b/tests/test_async_mode.py @@ -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 diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py index abe6038..8a973e3 100644 --- a/tests/test_cli_help.py +++ b/tests/test_cli_help.py @@ -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 diff --git a/tests/test_codeconfig.py b/tests/test_codeconfig.py index ea9a997..baeaeb5 100644 --- a/tests/test_codeconfig.py +++ b/tests/test_codeconfig.py @@ -45,16 +45,29 @@ def test_stream_mode_env_and_toml_are_deprecated_and_ignored(isolated, tmp_path) assert cfg.sources["stream_mode"] == "default" +def test_async_mode_toml_is_deprecated_and_ignored(isolated, tmp_path): + # gh #88: async_mode has no effect since ADR 0003 collapsed every turn onto the one + # async AG-UI path, so it is no longer resolved from `[ui] async_mode`. A config file + # that still sets it must keep LOADING (the key is ignored, never an error) — a + # langstage.toml that suddenly failed to resolve would be a far worse regression than + # the dead knob it retires. + _toml(tmp_path, '[agent]\nspec = "a.py:g"\n[ui]\nasync_mode = true\n') + cfg = CodeConfig.resolve(env={}, toml_start=tmp_path) + assert cfg.async_mode is False # default, TOML not applied + assert cfg.sources["async_mode"] == "default" + # ...and the rest of the file still resolves normally. + assert cfg.agent_spec == "a.py:g" + + def test_toml_keys(isolated, tmp_path): _toml( tmp_path, - '[agent]\nspec = "a.py:g"\ngraph_name = "myg"\n[ui]\nverbose = true\nasync_mode = true\n', + '[agent]\nspec = "a.py:g"\ngraph_name = "myg"\n[ui]\nverbose = true\n', ) cfg = CodeConfig.resolve(env={}, toml_start=tmp_path) assert cfg.agent_spec == "a.py:g" assert cfg.graph_name == "myg" assert cfg.verbose is True - assert cfg.async_mode is True def test_deepagent_spec_alias_is_deprecated(isolated, tmp_path): diff --git a/tests/test_show_config.py b/tests/test_show_config.py index 1e3400c..82692cf 100644 --- a/tests/test_show_config.py +++ b/tests/test_show_config.py @@ -82,11 +82,12 @@ def test_show_config_includes_the_configurable_table(tmp_path, monkeypatch): def test_show_config_omits_server_only_keys(): """The terminal CLI starts no server and titles the header from the graph name, so host/port/debug/title are inert and must not be advertised (gh #36). - stream_mode is likewise omitted — it's a deprecated no-op (gh #62).""" + stream_mode is likewise omitted — it's a deprecated no-op (gh #62) — and so is + async_mode, inert since ADR 0003 collapsed everything onto one path (gh #88).""" with CliRunner().isolated_filesystem(): r = CliRunner().invoke(main, ["--show-config"]) assert r.exit_code == 0, r.output - for key in ("host", "port", "debug", "title", "stream_mode"): + for key in ("host", "port", "debug", "title", "stream_mode", "async_mode"): assert not re.search(rf"^\s*{key}\s*=", r.output, re.MULTILINE), f"{key} should be omitted" # ...but keys the CLI actually honors are still shown. assert re.search(r"^\s*agent_spec\s*=", r.output, re.MULTILINE), r.output