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
31 changes: 31 additions & 0 deletions mcp/servers/witan/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
a MINOR bump may include breaking changes).

## [0.23.0] - 2026-08-21

### Fixed

- **CLI renderers no longer drop bracketed text from stored content.** Rich
reads `[...]` in `Console.print` as a style tag, so a task resolution saying
`code_transport is not set on [targets.production]` printed as
`code_transport is not set on ,` — the identifier naming *which* target was
misconfigured was the part removed, and nothing indicated anything was
missing. Reading the same task back through `task_get` showed the stored text
intact, so this was always display-only.

0.18.0 escaped the four `witan serve` startup sites it had just written, one
call site at a time; every other renderer kept printing stored text straight
into markup. The escaping now happens at the two shared boundaries —
`render_table` per cell, and `esc()`/`print_error()` for the line-oriented
renderers — rather than per call site, since per-site escaping is exactly how
the first fix left the rest broken. `witan task show`, `task close`,
`project show`, `project status`, `trace show`, `session list`, `migrate`,
`whoami` and the pickers all print stored text whole again.

Dry-run prompt output (`witan task run --dry-run`) turns off all three of
Rich's substitutions rather than escaping — it exists to show the exact text
the agent will receive, and `markup=False` alone still rendered a prompt
saying `:warning:` as ⚠ while the agent got the literal characters.

Most of these went silently: Rich drops a tag it cannot resolve to a style,
so `[rank]`, `[targets.production]` and a markdown `[link]` all just vanish.
One shape is worse — a bracketed absolute path (`[/var/lib/witan]`) parses as
a *closing* tag and raises `MarkupError`, taking the whole command down.

## [0.22.0] - 2026-08-21

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions mcp/servers/witan/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "witan-council"
version = "0.22.0"
version = "0.23.0"
description = "witan — agent memory, planning, and collaboration graph (work-coordination layer + umbrella CLI)"
readme = "README.md"
license = "BSD-3-Clause"
Expand Down Expand Up @@ -180,7 +180,7 @@ packages = ["witan"]
"schema" = "schema"

[tool.bumpversion]
current_version = "0.22.0"
current_version = "0.23.0"
allow_dirty = true

[[tool.bumpversion.files]]
Expand Down
170 changes: 170 additions & 0 deletions mcp/servers/witan/tests/test_cli_markup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Stored text reaches the terminal whole, brackets included.

Rich reads ``[...]`` in ``Console.print`` as a style tag, so any stored value
holding a TOML section, a Python repr, or a markdown link renders with that
substring silently removed — and nothing says it was. It was found on a task
resolution that named the misconfigured target: ``[targets.production]`` was
gone from the printed sentence while ``task_get`` showed it stored intact.

agent-kit#261 fixed the two ``witan serve`` call sites it had just written and
every other renderer stayed broken, so these tests assert the escaping at the
two shared boundaries (``esc``/``print_error`` and ``render_table``) and then
through a command that prints stored content. They render through a real
console: markup is eaten at render time, so a test that captures ``print``'s
arguments passes while the user sees the hole.
"""

from __future__ import annotations

import pytest

# The exact string from the report: a TOML table header, which Rich reads as a
# style named `targets.production` and drops when it cannot resolve it.
BRACKETED = (
"code_transport is not set on [targets.production], so code graphs stay local"
)


@pytest.fixture
def render():
"""Run a CLI renderer and return what a terminal would actually show."""
from witan.cli._common import console

console.width = 200 # wide, so wrapping never splits a string under test

def _render(fn, *args, **kwargs):
with console.capture() as capture:
fn(*args, **kwargs)
return capture.get()

return _render


def test_esc_keeps_a_toml_section_in_the_rendered_line(render):
from witan.cli._common import console, esc

out = render(console.print, f"resolution: {esc(BRACKETED)}")

assert "[targets.production]" in out


def test_an_error_naming_the_config_block_to_fix_still_names_it(render):
# Error text is the worst place to lose brackets: witan's own refusals name
# the block to edit, and that name is the whole point of the sentence.
from witan.cli._common import print_error

out = render(print_error, ValueError("unset `remote_url` on target [qa]"))

assert "[qa]" in out


def test_a_table_cell_keeps_its_brackets(render):
from witan.cli._common import render_table

out = render(
render_table,
title="Tasks",
columns=["slug", "title"],
rows=[{"slug": "tk-x", "title": "fix [targets.production]"}],
)

assert "[targets.production]" in out


def test_styling_a_column_still_works_after_escaping(render):
# The escape must not swallow the styles the renderer itself applies —
# those are markup we wrote, not data we were handed.
from witan.cli._common import _STATUS_STYLE, render_table

out = render(
render_table,
title="Tasks",
columns=["status", "title"],
rows=[{"status": "blocked", "title": "x"}],
styles={"status": _STATUS_STYLE},
)

assert "blocked" in out
assert "[red]" not in out # consumed as a style, not printed as text


def _stub_server(**tools):
"""A server exposing only the named tools, and no ``client``."""

class _Stub:
def __getattr__(self, name):
if name in tools:
return tools[name]
raise AssertionError(f"unexpected attribute: {name}")

return _Stub()


def test_task_show_prints_a_resolution_containing_a_toml_section(render, monkeypatch):
"""The originally observed defect, end to end."""
from witan.cli import _common
from witan.cli.tasks import _task_show

task = {
"slug": "tk-x",
"title": "witan serve falls back to the local store",
"type": "bug",
"priority": "p2",
"status": "closed",
"description": f"Seen when {BRACKETED}",
"resolution": BRACKETED,
}
monkeypatch.setattr(
_common,
"_server",
_stub_server(task_get=lambda slug: task, task_list=lambda parent: []),
)

out = render(_task_show, "tk-x")

assert out.count("[targets.production]") == 2 # description and resolution


def test_a_bracketed_path_does_not_take_the_command_down(render, monkeypatch):
# The one shape that fails loudly instead of silently: `[/var/lib/witan]`
# parses as a CLOSING tag with nothing open, which raises MarkupError —
# so an un-escaped store path in a description kills the whole command
# rather than losing its own substring.
from witan.cli import _common
from witan.cli.tasks import _task_show

task = {
"slug": "tk-y",
"title": "recall() drops rows where [rank] is null",
"status": "open",
"priority": "p1",
"type": "bug",
"description": "the store at [/var/lib/witan/graph.omni] is stale",
}
monkeypatch.setattr(
_common,
"_server",
_stub_server(task_get=lambda slug: task, task_list=lambda parent: []),
)

out = render(_task_show, "tk-y")

assert "[rank]" in out
assert "[/var/lib/witan/graph.omni]" in out


def test_a_dry_run_prompt_is_shown_exactly_as_the_agent_will_receive_it(render):
"""The one place escaping is the wrong answer, and `markup=False` is half of it.

A dry run exists to show the text that will be handed to the agent, so
escapes must not appear in it — but Rich substitutes emoji codes and
highlights literals independently of markup, so `:warning:` in a task
description rendered as ⚠ while the agent received the eight original
characters. Same class of lie, different Rich feature.
"""
from witan.cli.run_helpers import _launch_agent

prompt = "Fix the :warning: banner in [targets.production] before 3.14"
out = render(_launch_agent, None, "claude", None, prompt, True)

assert out.strip() == prompt
14 changes: 5 additions & 9 deletions mcp/servers/witan/witan/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
tasks, # noqa: F401
traces, # noqa: F401
)
from ._common import app, console, stderr_console
from ._common import app, console, print_error, stderr_console
from .migrate import migrate_app
from .output import OutputFormat, set_output_format
from .run_helpers import _run_task_slug
Expand Down Expand Up @@ -156,7 +156,7 @@ def _serve_target(transport: str):
try:
remote = cfg_module.load_remote_config()
except ValueError as exc:
stderr_console.print(f"[red]{escape(str(exc))}[/red]")
print_error(exc, stderr=True)
raise SystemExit(1) from None

if remote is None:
Expand All @@ -165,19 +165,15 @@ def _serve_target(transport: str):
return witan_mcp

if transport != "stdio":
stderr_console.print(
f"[red]{escape(remote_serving_needs_stdio(remote, transport))}[/red]"
)
print_error(remote_serving_needs_stdio(remote, transport), stderr=True)
raise SystemExit(1) from None

from ..remote.serve import build_remote_server

try:
server = asyncio.run(build_remote_server(remote))
except Exception as exc: # noqa: BLE001 — every failure mode is the same answer
stderr_console.print(
f"[red]{escape(remote_startup_failure(remote, exc))}[/red]"
)
print_error(remote_startup_failure(remote, exc), stderr=True)
raise SystemExit(1) from None
_warn_if_code_graph_is_local()
return server
Expand Down Expand Up @@ -300,7 +296,7 @@ def run(
try:
cfg = cfg_module.load(target=target)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
print_error(exc)
raise SystemExit(1) from None
_run_task_slug(
slug, cfg=cfg, agent=agent, model=model, claim=claim, dry_run=dry_run
Expand Down
38 changes: 33 additions & 5 deletions mcp/servers/witan/witan/cli/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Literal

from rich.console import Console
from rich.markup import escape
from rich.table import Table
from witan_core.cli import make_app

Expand Down Expand Up @@ -70,7 +71,7 @@ def _srv():
try:
remote = cfg_module.load_remote_config()
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
print_error(exc)
raise SystemExit(1) from None
if remote is not None:
_server = remote_proxy(remote)
Expand Down Expand Up @@ -144,9 +145,36 @@ def _split_csv(items: list[str] | None) -> list[str] | None:
}


def esc(value: object) -> str:
"""Escape stored graph content for interpolation into a markup string.

Rich reads square brackets in ``Console.print`` as style tags, so text
holding a TOML section (``[targets.production]``), a Python repr, or a
markdown link renders with that substring silently dropped — a resolution
that named which target was misconfigured turns into one that names none.
Nothing indicates anything was removed.

Every renderer that prints stored content goes through this or
:func:`render_table`, which applies it per cell. Escape at the boundary
where graph text meets markup, not at each call site: agent-kit#261 fixed
two sites that way and every other one stayed broken.
"""
return escape("" if value is None else str(value))


def print_error(message: object, *, stderr: bool = False) -> None:
"""Print ``message`` in red, escaped.

Error text is the worst place to drop a bracketed substring: witan's own
refusals name the config section to fix (``[targets.production]``), and an
omnigraph error quotes the query it choked on. Both are markup to Rich.
"""
(stderr_console if stderr else console).print(f"[red]{esc(message)}[/red]")


def _styled(value: str, table: dict) -> str:
style = table.get(value)
return f"[{style}]{value}[/{style}]" if style else (value or "")
return f"[{style}]{esc(value)}[/{style}]" if style else esc(value)


def _short_repo(uri: str | None) -> str:
Expand Down Expand Up @@ -206,12 +234,12 @@ def render_table(
for col in columns:
value = str(r.get(col, ""))
if not value and col in placeholders:
cells.append(f"[dim]{placeholders[col]}[/dim]")
cells.append(f"[dim]{esc(placeholders[col])}[/dim]")
elif col in styles:
cells.append(_styled(value, styles[col]))
elif value and col in dim_if_present:
cells.append(f"[dim]{value}[/dim]")
cells.append(f"[dim]{esc(value)}[/dim]")
else:
cells.append(value)
cells.append(esc(value))
table.add_row(*cells)
console.print(table)
Loading
Loading