fix(cli): /diff session reads the session's checkpoint policy and renders in full

/diff session on a rewired classic CLI went through slash.exec to the
messaging /diff handler. That handler builds its CheckpointManager from the
profile config. A `hermes --checkpoints` launch enables checkpoints only in
the session's frozen policy, so with the default `checkpoints.enabled: false`
it answered "not enabled" even after the session's tools had saved
checkpoints. Its output was also chat text, cut to 60 lines / 3000 chars and
fenced, where the terminal renderer shows 400 lines.

The owner now serves rollback.session_diff next to rollback.list/diff/restore.
It resolves the manager from the session's frozen checkpoint policy, as
rollback already does, applies the same container-backend refusal, and
returns main's session_diff result as data. The body is bounded to stay
inside one frame, and diff_lines carries the full line count. The CLI hands
that result to main's terminal renderer: the session-diff tail of
_print_session_diff moves into print_session_diff, shared by the local and
gateway paths, and _print_diff_body takes the sender's uncut line count for
its "N more lines" note. The method is declared in CANONICAL_METHODS and the
generated TS/OpenRPC contracts are regenerated.

Refs NousResearch#134889
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-10-14 20:47:20 -04:00
1 parent ecfcf103ad
commit 64f516c914
9 files changed
+254 -36

No files matched your search

@@ -4625,6 +4625,20 @@ export interface SessionStats {
provider: string
fallback_model?: string | null
}
export interface RollbackSessionDiffParams {
session_id: string
profile?: string | null
}
/** ``gateway/session_tui_ops.py::_session_diff``: ``CheckpointManager.session_diff`` under the session's frozen checkpoint policy (``enabled: false`` when that policy does not checkpoint). ``diff`` is bounded to fit one frame; ``diff_lines`` counts the whole body. */
export interface RollbackSessionDiffResult {
enabled: boolean
success?: boolean
stat?: string
diff?: string
diff_lines?: number
empty?: boolean
error?: string | null
}
export interface SessionAutomationParams {
session_id: string
kind: 'goal' | 'loop' | 'heartbeat'
@@ -6670,6 +6684,8 @@ export interface CanonicalRpcMethods {
/** Acknowledge a turn lost across an owner restart; the FIFO behind it resumes. Never requeues the lost input. */
'prompt.resolve_unknown': { params: PromptResolveUnknownParams; result: AdmissionStatus }
'prompt.submit': { params: CanonicalSubmitParams; result: AdmissionStatus }
/** The classic CLI's /diff session: everything changed since the session's earliest retained checkpoint, as data for its terminal renderer. */
'rollback.session_diff': { params: RollbackSessionDiffParams; result: RollbackSessionDiffResult }
/** Owner identity and the canonical capabilities a client may rely on. */
'runtime.describe': { params: RuntimeDescribeParams; result: RuntimeDescribeResult }
/** The classic CLI's /goal, /subgoal, /loop, /heartbeat manager calls (state writes, the goal judge, quality gates), run by the owner on the session's row. */
@@ -6725,6 +6741,7 @@ export const CANONICAL_RPC_METHODS = [
'prompt.receipt',
'prompt.resolve_unknown',
'prompt.submit',
'rollback.session_diff',
'runtime.describe',
'session.automation',
'session.create',
@@ -32683,6 +32683,84 @@
"title": "RollbackRestoreResult",
"type": "object"
},
"RollbackSessionDiffParams": {
"additionalProperties": false,
"properties": {
"session_id": {
"title": "Session Id",
"type": "string"
},
"profile": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Profile"
}
},
"required": [
"session_id"
],
"title": "RollbackSessionDiffParams",
"type": "object"
},
"RollbackSessionDiffResult": {
"additionalProperties": false,
"description": "``gateway/session_tui_ops.py::_session_diff``: ``CheckpointManager.session_diff`` under the\nsession's frozen checkpoint policy (``enabled: false`` when that policy does not checkpoint).\n``diff`` is bounded to fit one frame; ``diff_lines`` counts the whole body.",
"properties": {
"enabled": {
"title": "Enabled",
"type": "boolean"
},
"success": {
"default": false,
"title": "Success",
"type": "boolean"
},
"stat": {
"default": "",
"title": "Stat",
"type": "string"
},
"diff": {
"default": "",
"title": "Diff",
"type": "string"
},
"diff_lines": {
"default": 0,
"title": "Diff Lines",
"type": "integer"
},
"empty": {
"default": false,
"title": "Empty",
"type": "boolean"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Error"
}
},
"required": [
"enabled"
],
"title": "RollbackSessionDiffResult",
"type": "object"
},
"Room": {
"additionalProperties": false,
"description": "``gateway/hosted_rooms.py::_room_from_row`` plus the branch-only keys ``create`` (legacy\nadoption), ``state`` (``authority_claim``) and ``rename`` (``event``) add.",
@@ -46666,6 +46744,24 @@
}
}
},
{
"name": "rollback.session_diff",
"summary": "The classic CLI's /diff session: everything changed since the session's earliest retained checkpoint, as data for its terminal renderer.",
"params": [
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/RollbackSessionDiffParams"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/RollbackSessionDiffResult"
}
}
},
{
"name": "runtime.describe",
"summary": "Owner identity and the canonical capabilities a client may rely on.",
+28 -7
View File
@@ -1,6 +1,7 @@
"""Ink's session utilities on the shared owner: ``/save`` (``session.save``), ``/reload``
(``reload.env``), ``/agents pause|resume`` (``delegation.pause``) and ``/rollback``
(``rollback.list`` / ``rollback.diff`` / ``rollback.restore``).
(``rollback.list`` / ``rollback.diff`` / ``rollback.restore``), plus the classic CLI's ``/diff session``
(``rollback.session_diff``).
Main's per-TUI sidecar served these from its own session table, which never holds a shared-gateway
session, so the canonical TUI refused them. Each verb reuses main's helpers rather than re-deriving
@@ -26,6 +27,7 @@ def handlers(connection):
'rollback.list': partial(rollback, connection, kind='list'),
'rollback.diff': partial(rollback, connection, kind='diff'),
'rollback.restore': partial(rollback, connection, kind='restore'),
'rollback.session_diff': partial(rollback, connection, kind='session_diff'),
'session.context_breakdown': partial(context_breakdown, connection)}
@@ -140,11 +142,14 @@ async def pause_delegation(connection, ref, params):
_ROLLBACK_FIELDS = {'list': {'session_id', 'profile'}, 'diff': {'session_id', 'profile', 'hash'},
'restore': {'session_id', 'profile', 'hash', 'file_path'}}
'restore': {'session_id', 'profile', 'hash', 'file_path'},
'session_diff': {'session_id', 'profile'}}
# ``rollback.session_diff`` body bound: well inside one 8 MiB frame (the classic CLI shows 400 lines).
_SESSION_DIFF_CHARS = 2 * 1024 * 1024
async def rollback(connection, ref, params, *, kind):
"""``/rollback [list] | diff <n|hash> | <n|hash> [file]``: main's ``rollback.*`` over this
"""``/rollback [list] | diff <n|hash> | <n|hash> [file]`` and ``/diff session``: main's ``rollback.*`` over this
session's checkpoints (the shared ``<profile>/checkpoints`` store, keyed by the frozen launch cwd).
The manager is built from the session's frozen ``checkpoints`` policy rather than read off the
@@ -154,7 +159,7 @@ async def rollback(connection, ref, params, *, kind):
(main's ``_rewind_active_session_history``) through the owner's fenced ``rewind`` mutation."""
fields = _ROLLBACK_FIELDS[kind]
if any(key in params and (not isinstance(params[key], str) or (key == 'hash' and not params[key]))
for key in ('hash', 'file_path')) or (kind != 'list' and 'hash' not in params):
for key in ('hash', 'file_path')) or (kind in ('diff', 'restore') and 'hash' not in params):
raise RuntimeStoreError('invalid_params')
live = _live_ref(connection, ref, params, fields, 'session:read' if kind != 'restore' else 'session:control')
authority = connection.authority
@@ -172,15 +177,15 @@ async def rollback(connection, ref, params, *, kind):
kwargs = _checkpoint_agent_kwargs(policy.config(authority, secrets=False))
terminal = json.loads(policy.terminal_json)
def run():
def run() -> dict:
from tools.checkpoint_manager import CheckpointManager
from tui_gateway.checkpoints import _resolve_checkpoint_hash
mgr = CheckpointManager(enabled=bool(kwargs['checkpoints_enabled']), **{
key[len('checkpoint_'):]: value for key, value in kwargs.items() if key.startswith('checkpoint_')})
cwd = policy.cwd
if not mgr.enabled and kind in ('list', 'session_diff'):
return {'enabled': False, 'checkpoints': []} if kind == 'list' else {'enabled': False}
if kind == 'list':
if not mgr.enabled:
return {'enabled': False, 'checkpoints': []}
# The TUI renders ``message``; the manager calls it ``reason``.
return {'enabled': True, 'checkpoints': [
{'hash': c.get('hash', ''), 'timestamp': c.get('timestamp', ''), 'message': c.get('reason', '')}
@@ -193,6 +198,8 @@ async def rollback(connection, ref, params, *, kind):
refusal = mgr.unsupported_backend_reason(task_id)
finally:
reset_terminal_scope(token)
if kind == 'session_diff':
return _session_diff(mgr, cwd, refusal)
try:
target = _resolve_checkpoint_hash(mgr, cwd, params['hash'])
except ValueError as exc:
@@ -218,6 +225,20 @@ async def rollback(connection, ref, params, *, kind):
return result
def _session_diff(mgr, cwd, refusal):
"""``/diff session``: main's ``session_diff`` (earliest retained checkpoint vs the tree) as data,
so the classic CLI renders it with its own terminal renderer instead of chat-sized text."""
if refusal:
return {'enabled': True, 'success': False, 'error': refusal}
result = mgr.session_diff(cwd)
if not result.get('success'):
return {'enabled': True, 'success': False, 'error': str(result.get('error') or '')}
diff = result.get('diff', '')
body = diff if len(diff) <= _SESSION_DIFF_CHARS else diff[:_SESSION_DIFF_CHARS].rpartition('\n')[0]
return {'enabled': True, 'success': True, 'stat': result.get('stat', ''), 'diff': body,
'diff_lines': len(diff.splitlines()), 'empty': bool(result.get('empty'))}
async def _rewind_last_turn(connection, ref):
"""Drop the newest user turn and everything after it (main's full ``/rollback`` restore) via the
owner's revision-fenced ``rewind`` mutation; 0 when the transcript has no user turn."""
+6 -14
View File
@@ -664,13 +664,13 @@ class CLICommandsMixin(CLICommandsSessionToolsMixin):
if diff and not stat_only:
self._print_diff_body(diff, _t("diff.stat_hint"))
def _print_diff_body(self, diff: str, stat_hint: str, limit: int = 400) -> None:
"""Print a diff, capped at ``limit`` lines with a pointer to the --stat form."""
def _print_diff_body(self, diff: str, stat_hint: str, limit: int = 400, total: int = 0) -> None:
"""Print a diff, capped at ``limit`` lines (of ``total`` when its sender cut it) with a pointer to the --stat form."""
print()
diff_lines = diff.splitlines()
if len(diff_lines) > limit:
if (total := max(total, len(diff_lines))) > limit:
self._print_diff_text("\n".join(diff_lines[:limit]))
print(f"\n {_t('diff.more_lines', count=len(diff_lines) - limit, hint=stat_hint)}")
print(f"\n {_t('diff.more_lines', count=total - limit, hint=stat_hint)}")
else:
self._print_diff_text(diff)
@@ -681,16 +681,8 @@ class CLICommandsMixin(CLICommandsSessionToolsMixin):
return
if reason := mgr.unsupported_backend_reason(): # host baseline is not this session's tree
return print(f" {reason}")
result = mgr.session_diff(cwd)
if not result.get("success"):
return print(f" {result.get('error') or _t('shared.could_not_generate_diff')}")
stat, diff = result.get("stat", ""), result.get("diff", "")
if result.get("empty") or (not stat and not diff):
return print(f" {_t('diff.no_session_changes')}")
if stat:
self._print_diff_text(f"\n{stat}")
if diff and not stat_only:
self._print_diff_body(diff, _t("diff.stat_hint_session"))
from hermes_cli.cli_commands_session_tools import print_session_diff
print_session_diff(self, mgr.session_diff(cwd), stat_only)
def _print_diff_text(self, text: str) -> None:
"""Render diff/stat text with color when a rich console is present; plain print otherwise
+14
View File
@@ -291,6 +291,20 @@ class CLICommandsSessionToolsMixin:
f" {_t('profile.home', home=reply.data['home'])}")
def print_session_diff(cli, result: dict, stat_only: bool) -> None:
"""Render a ``CheckpointManager.session_diff`` result (``/diff session``): this process's agent's,
or the gateway session's ``rollback.session_diff`` (``diff_lines``: its uncut body's line count)."""
if not result.get("success"):
return print(f" {result.get('error') or _t('shared.could_not_generate_diff')}")
stat, diff = result.get("stat", ""), result.get("diff", "")
if result.get("empty") or (not stat and not diff):
return print(f" {_t('diff.no_session_changes')}")
if stat:
cli._print_diff_text(f"\n{stat}")
if diff and not stat_only:
cli._print_diff_body(diff, _t("diff.stat_hint_session"), total=int(result.get("diff_lines") or 0))
def _print_local_delegations() -> None:
"""This process's background (async) delegations — delegate_task(background=true)."""
delegations = _probe("tools.async_delegation", "list_async_delegations", [])
+11 -11
View File
@@ -4,9 +4,9 @@ Rewired, the session's tools, checkpoints, environment and handoff watcher run i
main's handlers (which read this process's own agent and environment) answer nothing or the wrong
thing:
- ``/diff session``: the checkpoint baseline is the gateway tools'; its ``/diff`` handler (a reviewed
``slash.exec`` read, run in this session's cwd) renders it. Working-tree modes stay main's local
git read of the same checkout.
- ``/diff session``: the checkpoint baseline is the gateway tools'. ``rollback.session_diff`` reads it
under the session's frozen checkpoint policy (``--checkpoints`` included) and returns it as data
for main's terminal renderer. Working-tree modes stay main's local git read of the same checkout.
- ``/reload``: ``reload.env`` re-reads ``.env`` where turns and tools run (this process re-reads too).
- ``/handoff``: no gateway verb carries a handoff yet; refused as Ink and Desktop refuse it, instead
of the store's ``request_handoff`` refusal dying silently in the process loop.
@@ -18,15 +18,15 @@ from hermes_cli.gateway_client import GatewayClientError
def _diff(cli, connection, cmd):
args = cmd.partition(" ")[2].strip()
if "session" not in args.lower().split():
args = cmd.partition(" ")[2].strip().lower().split()
if "session" not in args:
return False
from cli import _cprint
reply = connection.call("slash.exec", session_id=cli.session_id, command=f"diff {args}")
# The messaging handler fences its blocks for chat; a terminal shows the text itself.
for line in str((reply or {}).get("output") or "").splitlines():
if not line.startswith("```"):
_cprint(f" {line}" if line else "")
from hermes_cli.cli_commands_session_tools import _lines, _pr, _t, print_session_diff
result = connection.call("rollback.session_diff", session_id=cli.session_id) or {}
if not result.get("enabled"):
_pr(*_lines(_t("diff.not_enabled")))
else:
print_session_diff(cli, result, stat_only=bool({"--stat", "stat"} & set(args)))
return True
@@ -0,0 +1,40 @@
"""The classic CLI's ``/diff session`` on the shared owner (``rollback.session_diff``): the session's
frozen checkpoint policy decides, so a ``--checkpoints`` launch on a profile whose config leaves
checkpoints off still diffs, and the whole body comes back as data (no chat-sized cut)."""
import asyncio
import pytest
from tests.gateway.fixtures.local_recovery_probe import rpc, websocket
from tests.gateway.test_session_busy_controls import owner
from tests.gateway.test_session_tui_ops import _checkpoint, _git_rpc
@pytest.mark.platforms("linux")
def test_session_diff_follows_the_sessions_checkpoint_policy_and_keeps_the_whole_body(tmp_path):
with owner(tmp_path) as (home, _peer, desc):
work = tmp_path / 'work'
work.mkdir()
(work / 'big.txt').write_text(''.join(f'line {i}\n' for i in range(450)))
async def probe():
async with websocket(home, desc) as ws:
plain = (await rpc(ws, 'session.create', request_id='plain', source='cli', toolsets=[],
cwd=str(work)))['result']['session_id']
sid = (await rpc(ws, 'session.create', request_id='launch', source='cli', toolsets=[],
cwd=str(work), checkpoints=True))['result']['session_id']
await asyncio.to_thread(_checkpoint, home, work, 'before write_file')
(work / 'big.txt').write_text(''.join(f'edited {i}\n' for i in range(450)))
# The profile's config leaves checkpoints off: only the --checkpoints session diffs.
off = await _git_rpc(ws, 'rollback.session_diff', session_id=plain)
assert off.get('result', {}).get('enabled') is False, off
reply = await _git_rpc(ws, 'rollback.session_diff', session_id=sid)
assert 'result' in reply, reply
result = reply['result']
assert result['enabled'] is True and result['success'] is True, result
assert 'big.txt' in result['stat'] and '+edited 449' in result['diff'], result['stat']
assert result['diff_lines'] == len(result['diff'].splitlines()) > 900
for params, reason in [({'session_id': sid, 'hash': '1'}, 'invalid_params'),
({'session_id': sid, 'profile': 'foreign'}, 'profile_mismatch')]:
assert (await rpc(ws, 'rollback.session_diff', **params))['error']['message'] == reason
asyncio.run(probe())
+18 -4
View File
@@ -35,11 +35,25 @@ def gateway_cli(monkeypatch):
def test_diff_session_renders_the_gateway_sessions_checkpoint_diff(gateway_cli, capsys):
cli, conn = gateway_cli(**{"slash.exec": {"type": "exec", "output": "```\n a.py | 2 +-\n```"}})
assert cli.process_command("/diff session --stat") is True
assert conn.calls == [("slash.exec", {"session_id": "local-s", "command": "diff session --stat"})]
"""The gateway session's checkpoint diff comes back as data and gets main's terminal renderer:
400 body lines, not the chat handler's 60 lines / 3000 chars (Greptile 4277259989)."""
body = "\n".join(f"+edited {i}" for i in range(450))
cli, conn = gateway_cli(**{"rollback.session_diff": {
"enabled": True, "success": True, "stat": " a.py | 450 +", "diff": body, "diff_lines": 450}})
assert cli.process_command("/diff session") is True
assert conn.calls == [("rollback.session_diff", {"session_id": "local-s"})]
out = capsys.readouterr().out
assert "a.py | 2 +-" in out and "```" not in out
assert "a.py | 450 +" in out and "+edited 399" in out and "+edited 400" not in out
assert "50 more lines" in out and "```" not in out
cli.process_command("/diff session --stat")
out = capsys.readouterr().out
assert "a.py | 450 +" in out and "+edited 0" not in out
def test_diff_session_reports_a_session_without_checkpoints(gateway_cli, capsys):
cli, _conn = gateway_cli(**{"rollback.session_diff": {"enabled": False}})
assert cli.process_command("/diff session") is True
assert "Checkpoints are not enabled" in capsys.readouterr().out
def test_diff_of_the_working_tree_stays_a_local_git_read(gateway_cli, monkeypatch):
+24
View File
@@ -220,6 +220,30 @@ canonical_method("session.stats", params=SessionStatsParams, result=SessionStats
doc="The session.usage dict on demand: what main's classic CLI read off its agent.")
class RollbackSessionDiffParams(Params):
session_id: str
profile: str | None = None
class RollbackSessionDiffResult(Result):
"""``gateway/session_tui_ops.py::_session_diff``: ``CheckpointManager.session_diff`` under the
session's frozen checkpoint policy (``enabled: false`` when that policy does not checkpoint).
``diff`` is bounded to fit one frame; ``diff_lines`` counts the whole body."""
enabled: bool
success: bool = False
stat: str = ""
diff: str = ""
diff_lines: int = 0
empty: bool = False
error: str | None = None
canonical_method("rollback.session_diff", params=RollbackSessionDiffParams, result=RollbackSessionDiffResult,
doc="The classic CLI's /diff session: everything changed since the session's earliest "
"retained checkpoint, as data for its terminal renderer.")
class SessionAutomationParams(Params):
session_id: str
kind: Literal["goal", "loop", "heartbeat"]