From b70c66bdd64bb5fb3ec96b5169cddd87720a8f8a Mon Sep 17 00:00:00 2001 From: Kedar Date: Sat, 25 Jul 2026 11:13:03 -0400 Subject: [PATCH] fix: bare /config renders live state, not a frozen startup snapshot (gh #97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-table `/config` view printed `_resolved_config_report` — a string snapshotted at startup and never updated — while every other reader and setter uses the live `context`: `/config verbose on` sets `context["verbose"]`, `/config verbose` and `/status` read it back, and `/reset` mutates `context["config"]["configurable"]["thread_id"]`. So after `/verbose on` the table still showed `verbose = False [default]` even as `/status` said `on` and `/config verbose` said `True`, and after `/reset` it kept printing the pre-reset `thread_id`. The frozen string also carried a wrong SOURCE label — `[default]` for a value the user had overridden — so a user who set a value and re-ran `/config` to confirm saw it reported as unset and could conclude the set had silently failed. Bare `/config` now RE-RENDERS the one `describe()` diagnostic (the same renderer `--show-config` uses) from the `CodeConfig` resolved at startup, overlaid with live state: `verbose` from `context["verbose"]` (relabelled `[override]`, never `[default]`, when it differs from the resolved value) and the live `[configurable]` values (so a `/reset` thread_id shows through). Crucially it re-renders the startup-resolved cfg — whose per-field provenance is frozen — rather than RE-RESOLVING, so it still can't pick up the `LANGSTAGE_WORKSPACE_ROOT` that `apply_workspace()` self-publishes: the #64 fix holds and the no-mutation startup table stays byte-for-byte identical to `--show-config`. Same "two provenance views disagree" family as #64 / #66 / #79, now closed for the full `/config` table. `--show-config` is unaffected. Regression tests drive the real interactive loop: flip verbose (via `/verbose` and `/config verbose on`), then assert the bare `/config` table reflects it with an `[override]` source and agrees with `/status` and the single-key read; and a `/reset` test asserts the `[configurable]` thread_id tracks the reset. All three fail before the fix and pass after. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011HWCfJii6gXd3XL3Gq3W8B --- CHANGELOG.md | 25 +++++++++++ langstage_cli/cli.py | 86 +++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- tests/test_config_command.py | 55 ++++++++++++++++++++++- 4 files changed, 154 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e1752..8964aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.6.25 - 2026-07-25 + +### Fixed +- **Bare `/config` now renders the LIVE configuration, so after a runtime change it agrees with + `/status`, the single-key `/config ` read, and its own `✓ Set` line instead of + contradicting all three (gh #97).** The full-table `/config` view printed + `_resolved_config_report` — a string snapshotted at startup and never updated — while every + other reader and setter uses the live `context`: `/config verbose on` sets `context["verbose"]`, + `/config verbose` and `/status` read it back, and `/reset` mutates + `context["config"]["configurable"]["thread_id"]`. So after `/verbose on` the table still showed + `verbose = False [default]` even as `/status` said `on` and `/config verbose` said `True`, and + after `/reset` it kept printing the pre-reset `thread_id`. Worse, the frozen string carried a + wrong **source** label — `[default]` for a value the user had actually overridden — so a user + who set a value and re-ran `/config` to confirm saw it reported as unset and could reasonably + conclude the set had silently failed. Bare `/config` now RE-RENDERS the one `describe()` + diagnostic (the same renderer `--show-config` uses) from the `CodeConfig` resolved at startup, + overlaid with live state: `verbose` from `context["verbose"]` (relabelled `[override]`, never + `[default]`, when it differs from the resolved value) and the live `[configurable]` values (so a + `/reset` thread_id shows through). Crucially this RE-RENDERS the startup-resolved cfg — whose + per-field provenance is frozen — rather than RE-RESOLVING, so it still can't pick up the + `LANGSTAGE_WORKSPACE_ROOT` that `apply_workspace()` self-publishes: the #64 fix holds and the + no-mutation startup table stays byte-for-byte identical to `--show-config`. Same + "two provenance views disagree" family as #64 / #66 / #79, now closed for the full `/config` + table. `--show-config` (the non-interactive flag) is unaffected. + ## 0.6.24 - 2026-07-25 ### Fixed diff --git a/langstage_cli/cli.py b/langstage_cli/cli.py index 13964e4..7a7a9d4 100644 --- a/langstage_cli/cli.py +++ b/langstage_cli/cli.py @@ -4,6 +4,7 @@ """ import asyncio +import copy import json import os import re @@ -1127,6 +1128,63 @@ def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]: return None +def _live_resolved_report(config: Dict[str, Any], context: Dict[str, Any]) -> str: + """Render the full resolved-config diagnostic from LIVE runtime state (gh #97). + + Bare ``/config`` used to print ``_resolved_config_report`` — a string frozen at + startup — so after a runtime mutation (``/verbose``, ``/config verbose on``, + ``/reset``) it contradicted ``/status``, the single-key ``/config `` read, and + even its own ``✓ Set`` line, and mislabelled an overridden value ``[default]``. + + Instead we re-render the ONE ``describe()`` diagnostic from the ``CodeConfig`` + resolved at startup, overlaid with live state: ``verbose`` from + ``context["verbose"]`` and the live ``[configurable]`` values (so a ``/reset`` + thread_id shows through). The stored cfg keeps its startup-frozen + ``_sources``/``_toml_paths``, so this RE-RENDERS rather than RE-RESOLVES — it can't + pick up the ``LANGSTAGE_WORKSPACE_ROOT`` that ``apply_workspace`` self-publishes, so + the #64 fix still holds and static keys keep their true provenance. + + A field changed at runtime is relabelled ``[override]`` (never ``[default]``), + reusing ``describe()``'s own source vocabulary so the live view agrees with + ``/status`` and ``/config ``. Falls back to the frozen snapshot string only + when the resolved cfg is unavailable (e.g. a hand-built test context). + """ + resolved_cfg = config.get("_resolved_config") + if resolved_cfg is None: + # No live cfg to render from: honour the startup snapshot as-is, and only + # re-resolve if even that is absent (never re-resolve otherwise — gh #64). + report = config.get("_resolved_config_report") + if report is None: + from langstage_cli.config import CodeConfig + + report = CodeConfig.resolve().describe( + omit_keys=_INERT_KEYS, configurable=config.get("configurable") or None + ) + return report + + # Overlay the one runtime-mutable field (verbose) onto a copy, keeping every static + # key's frozen provenance. Relabel it [override] only when it actually differs from + # the startup-resolved value, so an unchanged value keeps its true source. + live_cfg = copy.copy(resolved_cfg) + live_cfg._sources = dict(resolved_cfg.sources) + live_verbose = context.get("verbose", resolved_cfg.verbose) + if live_verbose != resolved_cfg.verbose: + live_cfg.verbose = live_verbose + live_cfg._sources["verbose"] = "override" + + # Overlay live [configurable] values onto the startup key set, so /reset's new + # thread_id shows through while the table's shape stays byte-identical to startup + # (a key absent at startup — e.g. an auto thread_id when the TOML set none — stays + # hidden, keeping /config's table matching --show-config's). + snap_conf = config.get("_snap_configurable") + live_conf = config.get("configurable") or {} + overlaid = None + if isinstance(snap_conf, dict) and snap_conf: + overlaid = {k: live_conf.get(k, v) for k, v in snap_conf.items()} + + return live_cfg.describe(omit_keys=_INERT_KEYS, configurable=overlaid) + + @register_command( name="config", description="Show or set configuration", @@ -1150,18 +1208,13 @@ def cmd_config(args: str, context: Dict[str, Any]) -> Optional[str]: print(f" {DIM}TOML sources:{RESET} {DIM}(none — using defaults){RESET}") # Full resolved view — the COMPLETE describe() diagnostic (fields + source + - # env/TOML keys + the [configurable] table). Prefer the snapshot captured at - # startup (before apply_workspace self-published LANGSTAGE_WORKSPACE_ROOT), so - # workspace_root's source is truthful and /config renders byte-for-byte what - # --show-config prints — both go through the one describe() (gh #64, #66). - # Fall back to a fresh resolve only if the snapshot is somehow absent. - report = config.get("_resolved_config_report") - if report is None: - from langstage_cli.config import CodeConfig - - report = CodeConfig.resolve().describe( - omit_keys=_INERT_KEYS, configurable=config.get("configurable") or None - ) + # env/TOML keys + the [configurable] table), RE-RENDERED from live state so it + # reflects runtime mutations (/verbose, /config verbose on, /reset) and agrees + # with /status and /config instead of contradicting them (gh #97). This + # re-renders the startup-resolved cfg (frozen provenance) rather than + # re-resolving, so workspace_root's source stays truthful — the #64 fix holds — + # and the startup table still matches --show-config byte-for-byte (gh #64, #66). + report = _live_resolved_report(config, context) for line in report.splitlines(): print(f" {line}") print() @@ -1954,6 +2007,15 @@ def main( # The resolved-config diagnostic snapshotted before apply_workspace, so /config # reports the true source of workspace_root instead of the self-published env (gh #64). config_dict["_resolved_config_report"] = resolved_config_report + # Also stash the resolved CodeConfig object itself (with its startup-frozen + # provenance) and the snapshot [configurable] key set, so bare /config can + # RE-RENDER the same describe() diagnostic from live state — reflecting runtime + # mutations (/verbose, /config verbose on, /reset) instead of the frozen string, + # while still never re-resolving (so #64's self-published-env fix holds). (gh #97) + config_dict["_resolved_config"] = cfg + config_dict["_snap_configurable"] = ( + _snap_configurable if isinstance(_snap_configurable, dict) else None + ) # Extract agent name and description from graph object agent_name = get_agent_name(graph) diff --git a/pyproject.toml b/pyproject.toml index 672b2e3..06c7225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "langstage-cli" -version = "0.6.24" +version = "0.6.25" 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_config_command.py b/tests/test_config_command.py index 25cd548..9e2b253 100644 --- a/tests/test_config_command.py +++ b/tests/test_config_command.py @@ -6,9 +6,20 @@ `workspace_root`'s source as `[env:LANGSTAGE_WORKSPACE_ROOT]` even when the user never set it — diverging from `--show-config` (which runs before `apply_workspace`). `/config` now reuses the report snapshotted at startup, before the self-publish. + +The bare `/config` table also used to be rendered from a STRING frozen at startup, so after +a runtime mutation (`/verbose`, `/config verbose on`, `/reset`) it contradicted `/status`, +the single-key `/config ` read, and even its own `✓ Set` line — and mislabelled an +overridden value `[default]` (gh #97). It now RE-RENDERS the one `describe()` diagnostic +from the startup-resolved cfg overlaid with live state, so the live view stays consistent +while still not re-resolving (so the #64 fix above is preserved). """ -from langstage_cli.cli import cmd_config +import re + +from click.testing import CliRunner + +from langstage_cli.cli import cmd_config, main def test_config_uses_startup_snapshot_not_a_reresolve(monkeypatch, capsys): @@ -38,3 +49,45 @@ def test_config_uses_startup_snapshot_not_a_reresolve(monkeypatch, capsys): # ...and does NOT misreport the self-published env var as the source (the #64 bug). assert "[env:LANGSTAGE_WORKSPACE_ROOT]" not in out assert "/abs/self/published/ws" not in out + + +def test_bare_config_reflects_runtime_verbose_mutation(): + # gh #97: drive the real interactive loop — flip verbose ON, then the bare `/config` + # table must show the LIVE value with a non-[default] source, agreeing with the + # single-key `/config verbose` read and `/status`. Before the fix this printed the + # frozen startup snapshot: `verbose = False [default]`, contradicting both. + with CliRunner().isolated_filesystem(): # no stray langstage.toml + r = CliRunner().invoke(main, ["--demo"], input="/verbose on\n/config\n/quit\n") + assert r.exit_code == 0, r.output + # The full-table verbose line reflects the mutation and is labelled [override]. + assert re.search(r"verbose\s*=\s*True\s*\[override\]", r.output), r.output + # And it is NOT the stale frozen snapshot (the #97 bug). + assert not re.search(r"verbose\s*=\s*False\s*\[default\]", r.output), r.output + + +def test_bare_config_agrees_with_status_and_single_key_after_verbose_on(): + # The three views that used to diverge must now agree after a mutation (gh #97): + # `/status` (on), single-key `/config verbose` (True), and the bare `/config` table. + with CliRunner().isolated_filesystem(): + r = CliRunner().invoke( + main, ["--demo"], input="/config verbose on\n/config verbose\n/status\n/config\n/quit\n" + ) + assert r.exit_code == 0, r.output + assert "verbose: True" in r.output # single-key read + assert re.search(r"Verbose:\s*on", r.output), r.output # /status + assert re.search(r"verbose\s*=\s*True\s*\[override\]", r.output), r.output # full table + + +def test_bare_config_reflects_reset_thread_id(tmp_path, monkeypatch): + # gh #97 (Repro B): a `[configurable] thread_id` shown by `/config` must track a + # runtime `/reset`, not keep printing the pre-reset value. + (tmp_path / "langstage.toml").write_text('[configurable]\nthread_id = "T-123"\n') + monkeypatch.chdir(tmp_path) + r = CliRunner().invoke(main, ["--demo"], input="/config\n/reset\n/config\n/quit\n") + assert r.exit_code == 0, r.output + # First /config shows the TOML value; after /reset the second /config shows the new + # thread_id, and the stale one no longer appears in the post-reset table. + pre, _, post = r.output.partition("Session reset") + assert "thread_id: T-123" in pre, r.output + assert "thread_id: T-123" not in post, r.output + assert re.search(r"thread_id:\s*[0-9a-f-]{16,}", post), r.output