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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <key>` 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
Expand Down
86 changes: 74 additions & 12 deletions langstage_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import asyncio
import copy
import json
import os
import re
Expand Down Expand Up @@ -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 <key>`` 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 <key>``. 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",
Expand All @@ -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 <key> 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()
Expand Down Expand Up @@ -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)
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.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"
Expand Down
55 changes: 54 additions & 1 deletion tests/test_config_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` 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):
Expand Down Expand Up @@ -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
Loading