feat(cli): add `honcho session view` transcript command (#1006)

* feat(cli): add `honcho session view` transcript command

Adds a read-only transcript view for a session, with three paging modes:
a tail window (`--last N`, the default), server pages (`--page N --size M`),
and the whole conversation (`--all`). `--reverse` selects newest-first in
every mode, `--ids` exposes message IDs, and `-p` scopes to one peer.
JSON mode emits the same shape as `message list`.

The renderer is deliberately literal: content and identifiers go through
`rich.text.Text` rather than Markdown or console markup, so newlines, tag
delimiters like `<thinking>`, and bracketed text survive intact — this is a
debugging surface, so it has to show what was actually stored. Timestamps
are converted to UTC (not just stripped of their offset) and keep
millisecond precision. Nothing is truncated with an ellipsis: a displayed
message ID is always usable with `honcho message get`.

Flags are validated before the client is built, and the session is
constructed directly instead of via the get-or-create `client.session()`,
so an invalid or mistyped invocation never reaches — or creates — anything
server-side. `--size` is bounded locally to the server's 100-item ceiling
rather than surfacing a raw 422, and the "more:" hint echoes back the size
and ordering actually in use so following it lands on the adjacent window.

Also fixes `honcho message list --last N`, which stopped at the first page
of 50: both commands now share the page-walking helper, so the same flag
returns the same window either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(cli): regenerate command reference for `session view`

Adds the generated `session view` accordion to the docs snippet and points
the session-debugging workflows at it. Trims the docstring to plain prose —
the RST double-backticks were rendering literally in `--help`, where every
other command uses unmarked flag names — and stops the generator emitting a
trailing blank line that tripped end-of-file-fixer on every regeneration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): carry invocation scope into the next-page hint

Addresses CodeRabbit review on #1006.

The "more:" hint echoed only `--page`, `--size`, and `--reverse`, so copying
it off a scoped invocation dropped `-w`, `-p`, and `--ids` — landing on a
different workspace or an unfiltered transcript. Hint construction moves into
`_next_page_command` in the command module, which knows the invocation; the
renderer now just prints the string it's handed and no longer needs to know
CLI flag syntax. Only flags passed explicitly are echoed, since anything from
the environment or config resolves the same way on the next run.

Also rejects non-positive `--last` on `honcho message list`, which slice
semantics turned into a silently empty result. `session view` already errored
on it; the two now agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): read next-page hint scope from effective flag overrides

Addresses the second CodeRabbit pass on #1006.

`-w`/`-p` parse at group and top level as well as command level, all landing
in `_global_overrides`, so reading the command-level params dropped the scope
from `honcho session -w ws2 view ...`. The hint now reads the effective
overrides via a new `get_flag_overrides()`, which deliberately excludes
environment and config values since those resolve the same way on the next run.

Also shell-quotes the hint's identifiers with `shlex.join`. Note this is
hardening rather than a live injection fix: the API constrains IDs to
`^[a-zA-Z0-9_-]+$`, so an ID carrying a space or metacharacter fails the fetch
before any hint is printed. `validate_resource_id` is looser than the server
though, so quoting is the cheaper invariant to hold locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aakash Kattelu 2026-08-11 10:12:16 -04:00 committed by GitHub
parent bd6163d9e3
commit 0d57df430e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 859 additions and 16 deletions

View File

@ -305,7 +305,7 @@ honcho peer set-metadata <metadata>
## honcho session
List, inspect, create, delete, and manage conversation sessions and their peers.
List, inspect, view, create, delete, and manage conversation sessions and their peers.
<AccordionGroup>
<Accordion title="add-peers">
@ -461,6 +461,48 @@ honcho session summaries [<session_id>]
<ParamField path="session_id" type="string" />
</Accordion>
<Accordion title="view">
View a session transcript as a chat log.
Modes (pick one):
- default / --last N: tail of the conversation (most recent N)
- --page N [--size M]: page through the full transcript
- --all: every message
Paging follows the requested order: --page 1 starts at the oldest message,
or the newest with --reverse.
Human mode prints a row-delimited table. JSON mode emits the message list
(same shape as message list).
```bash
honcho session view [<session_id>]
```
<ParamField path="session_id" type="string" />
<ParamField path="--last" type="number">
Show only the N most recent messages (default when no --page/--all: 50).
</ParamField>
<ParamField path="--page" type="number">
1-indexed page of the full transcript. Use for page 2+.
</ParamField>
<ParamField path="--size" type="number">
Messages per page; requires --page (1-100, default: 50).
</ParamField>
<ParamField path="--all" type="boolean">
Show the full transcript (every page).
</ParamField>
<ParamField path="--reverse" type="boolean">
Newest first (default is chronological: oldest at top).
</ParamField>
<ParamField path="--ids" type="boolean">
Include message IDs in the transcript.
</ParamField>
<ParamField path="--peer" type="string">
Filter by peer ID. Short alias: `-p`.
</ParamField>
</Accordion>
</AccordionGroup>
## honcho workspace

View File

@ -142,7 +142,7 @@ When you pick up a workspace and need to orient — start broad, narrow to the p
<Step title="Debug a session">
```bash
honcho session inspect <session_id> --json
honcho message list <session_id> --last 20 --json
honcho session view <session_id> --last 20
honcho session context <session_id> --json
honcho session summaries <session_id> --json
```
@ -150,7 +150,7 @@ When you pick up a workspace and need to orient — start broad, narrow to the p
</Steps>
<Tip>
`honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you.
`honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you. `honcho session view` shows the raw transcript that context was built from; it prints content verbatim, so tag-delimited and multi-line messages appear exactly as stored.
</Tip>
### A peer isn't learning
@ -176,7 +176,7 @@ When an agent's responses don't reflect what you expect it to know.
```bash
honcho session context <session_id> --json
honcho session summaries <session_id> --json
honcho message list <session_id> --last 50 --json
honcho session view <session_id> --last 50
```
### Dialectic returns bad answers

View File

@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
### Added
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session
### Fixed
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window
## [0.1.2] - 2026-07-20
### Added

View File

@ -71,6 +71,7 @@ Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-
| `honcho session list` | List sessions in the workspace (filter with `--peer/-p`) |
| `honcho session create <id>` | Create or get a session (optionally `--peers` to add peers, `--metadata`) |
| `honcho session inspect <id>` | Peers, message count, summaries, config |
| `honcho session view <id>` | Transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, `-p`) |
| `honcho session context <id>` | What an agent would see |
| `honcho session summaries <id>` | Short + long summaries |
| `honcho session peers <id>` / `add-peers` / `remove-peers` | Peer management |

View File

@ -230,7 +230,7 @@ def build() -> str:
body: list[str] = []
for name in sorted(root.commands):
body.extend(_render_top(root.commands[name], ["honcho", name]))
return HEADER + "\n".join(body) + "\n"
return HEADER + "\n".join(body).rstrip("\n") + "\n"
def main() -> int:

View File

@ -76,7 +76,7 @@ def print_welcome(console: Console) -> None:
("workspace", "list · create · search · delete · inspect · queue-status"),
("peer", "list · create · search · inspect · card · chat"),
("", "get-metadata · set-metadata · representation"),
("session", "list · create · search · delete · inspect · add-peers"),
("session", "list · create · search · delete · inspect · view · add-peers"),
("", "context · get-metadata · set-metadata · peers"),
("", "remove-peers · representation · summaries"),
("message", "list · create · get"),

View File

@ -10,7 +10,7 @@ import typer
from honcho.api_types import MessageCreateParams
from honcho_cli.commands.session import _get_session_id
from honcho_cli.commands.session import _fetch_recent_messages, _get_session_id
from honcho_cli.commands.workspace import _handle_error
from honcho_cli.output import print_error, print_result, status
from honcho_cli.validation import validate_resource_id
@ -37,15 +37,18 @@ def list_messages(
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
sid = _get_session_id(session_id)
if last < 1:
print_error("INVALID_FLAGS", "--last must be >= 1", {"last": last})
raise typer.Exit(1)
client, config = get_client()
sess = client.session(sid)
try:
filters = {"peer_id": config.peer_id} if config.peer_id else None
# Fetch newest-first so [:last] always gives the most recent N messages,
# then flip to oldest-at-top / newest-at-bottom for readable display.
# Fetch newest-first so we always get the most recent N messages, then
# flip to oldest-at-top / newest-at-bottom for readable display.
# --reverse keeps the raw server order (oldest first, descending in table).
msgs = sess.messages(filters=filters, reverse=True).items[:last]
msgs, _ = _fetch_recent_messages(sess, filters, last)
if not reverse:
msgs = list(reversed(msgs))

View File

@ -1,22 +1,29 @@
"""Session commands: list, inspect, context, summaries, peers, search, representation, metadata."""
"""Session commands: list, inspect, view, context, summaries, peers, search, representation, metadata."""
from __future__ import annotations
import json
import shlex
from typing import List, Optional
import typer
from honcho import HonchoError
from honcho import HonchoError, Session
from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.output import print_error, print_result, print_transcript, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
from honcho_cli.common import (
add_common_options,
get_client,
get_flag_overrides,
get_resolved_config,
handle_cmd_flags,
)
app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.")
app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, view, create, delete, and manage conversation sessions and their peers.")
add_common_options(app)
@ -135,6 +142,240 @@ def inspect(
_handle_error(e, "session", sid)
# Server-side ceiling on page size (fastapi-pagination's default ``Params``
# declares ``size`` as ``Query(50, ge=1, le=100)``).
MAX_PAGE_SIZE = 100
DEFAULT_PAGE_SIZE = 50
def _fetch_recent_messages(sess, filters: dict | None, last: int) -> tuple[list, int | None]:
"""Fetch the ``last`` most recent messages, newest first.
Walks as many newest-first server pages as it takes to fill the window.
Returns the messages plus the session's total message count (if reported).
"""
page = sess.messages(
filters=filters,
reverse=True,
size=min(max(last, 1), MAX_PAGE_SIZE),
)
total = page.total
msgs = list(page.items)
while len(msgs) < last and page.has_next_page():
page = page.get_next_page()
if page is None:
break
msgs.extend(page.items)
return msgs[:last], total
def _next_page_command(
session_id: str,
next_page: int,
size: int,
*,
reverse: bool,
show_ids: bool,
workspace: str | None,
peer: str | None,
) -> str:
"""Continuation command for the next page, carrying this invocation's scope.
Scoping flags are echoed only when passed as flags; anything resolved from
the environment or config file resolves the same way on the next run. IDs
are shell-quoted they may contain spaces and metacharacters, and this
string is meant to be pasted into a shell.
"""
parts = [
"honcho",
"session",
"view",
session_id,
"--page",
str(next_page),
"--size",
str(size),
]
if reverse:
parts.append("--reverse")
if show_ids:
parts.append("--ids")
if workspace:
parts += ["-w", workspace]
if peer:
parts += ["-p", peer]
return shlex.join(parts)
def _fetch_all_messages(sess, filters: dict | None) -> tuple[list, int | None]:
"""Fetch every message in the session, oldest first."""
page = sess.messages(filters=filters, reverse=False, size=MAX_PAGE_SIZE)
total = page.total
msgs = list(page.items)
while page.has_next_page():
page = page.get_next_page()
if page is None:
break
msgs.extend(page.items)
return msgs, total
@app.command()
def view(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
last: Optional[int] = typer.Option(
None,
"--last",
help=f"Show only the N most recent messages (default when no --page/--all: {DEFAULT_PAGE_SIZE})",
),
page_number: Optional[int] = typer.Option(
None,
"--page",
help="1-indexed page of the full transcript. Use for page 2+.",
),
size: Optional[int] = typer.Option(
None,
"--size",
help=f"Messages per page; requires --page (1-{MAX_PAGE_SIZE}, default: {DEFAULT_PAGE_SIZE})",
),
all_messages: bool = typer.Option(False, "--all", help="Show the full transcript (every page)"),
reverse: bool = typer.Option(
False,
"--reverse",
help="Newest first (default is chronological: oldest at top)",
),
show_ids: bool = typer.Option(False, "--ids", help="Include message IDs in the transcript"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""View a session transcript as a chat log.
Modes (pick one):
- default / --last N: tail of the conversation (most recent N)
- --page N [--size M]: page through the full transcript
- --all: every message
Paging follows the requested order: --page 1 starts at the oldest message,
or the newest with --reverse.
Human mode prints a row-delimited table. JSON mode emits the message list
(same shape as message list).
"""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
sid = _get_session_id(session_id)
# Validate every flag before touching the network.
modes = sum([
last is not None,
page_number is not None,
all_messages,
])
if modes > 1:
print_error(
"INVALID_FLAGS",
"--last, --page, and --all are mutually exclusive",
{"last": last, "page": page_number, "all": all_messages},
)
raise typer.Exit(1)
if page_number is not None and page_number < 1:
print_error("INVALID_FLAGS", "--page must be >= 1", {"page": page_number})
raise typer.Exit(1)
if size is not None and page_number is None:
print_error("INVALID_FLAGS", "--size only applies with --page", {"size": size})
raise typer.Exit(1)
if size is not None and not 1 <= size <= MAX_PAGE_SIZE:
print_error(
"INVALID_FLAGS",
f"--size must be between 1 and {MAX_PAGE_SIZE}",
{"size": size},
)
raise typer.Exit(1)
if last is not None and last < 1:
print_error("INVALID_FLAGS", "--last must be >= 1", {"last": last})
raise typer.Exit(1)
# Default: tail of conversation (most recent 50).
mode = "page" if page_number is not None else ("all" if all_messages else "last")
tail = last if last is not None else DEFAULT_PAGE_SIZE
page_size = size if size is not None else DEFAULT_PAGE_SIZE
client, config = get_client()
# Read-only: client.session() is a get-or-create POST, so build the Session directly.
sess = Session(sid, client)
try:
filters = {"peer_id": config.peer_id} if config.peer_id else None
page_meta: int | None = None
pages_meta: int | None = None
if mode == "page":
# Page in the order the caller asked for.
result_page = sess.messages(
filters=filters,
page=page_number,
size=page_size,
reverse=reverse,
)
msgs = list(result_page.items)
total = result_page.total
page_meta = result_page.page if result_page.page is not None else page_number
pages_meta = result_page.pages
elif mode == "all":
msgs, total = _fetch_all_messages(sess, filters)
if reverse:
msgs = list(reversed(msgs))
else:
# Tail window: fetched newest-first, flipped to chronological unless --reverse.
msgs, total = _fetch_recent_messages(sess, filters, tail)
if not reverse:
msgs = list(reversed(msgs))
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"content": m.content,
"token_count": m.token_count,
"metadata": m.metadata,
"created_at": str(m.created_at),
}
for m in msgs
]
except Exception as e:
_handle_error(e, "session", sid)
raise # unreachable: _handle_error always exits
next_page_hint = None
if page_meta is not None and pages_meta is not None and page_meta < pages_meta:
# Effective overrides, not the command-level params: -w/-p also parse at
# group and top level.
overrides = get_flag_overrides()
next_page_hint = _next_page_command(
sid,
page_meta + 1,
page_size,
reverse=reverse,
show_ids=show_ids,
workspace=overrides["workspace"],
peer=overrides["peer"],
)
# Rendered outside the try: output failures aren't session API errors.
print_transcript(
items,
session_id=sid,
total=total,
page=page_meta,
pages=pages_meta,
show_ids=show_ids,
next_page_hint=next_page_hint,
)
@app.command()
def context(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),

View File

@ -52,6 +52,15 @@ def get_resolved_config():
return config
def get_flag_overrides() -> dict[str, str | None]:
"""Workspace/peer/session as supplied by ``-w``/``-p``/``-s`` at any level.
Unlike :func:`get_resolved_config`, this excludes values coming from the
environment or config file.
"""
return dict(_global_overrides)
def maybe_refresh_token(config: CLIConfig) -> None:
"""Refresh an expired OAuth access token in place and persist it.

View File

@ -8,10 +8,12 @@ from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.text import Text
console = Console(stderr=True)
stdout_console = Console()
@ -102,3 +104,112 @@ def print_error(code: str, message: str, details: dict | None = None) -> None:
def status(msg: str) -> None:
"""Print a status message to stderr."""
console.print(f"[dim]{msg}[/dim]")
# Stable peer-color palette for transcript rendering. Brand blue first so the
# primary peer lands on brand when there's only one speaker.
_PEER_COLORS = (
"#B6DAFD", # brand
"#9ccfd8", # foam
"#c4a7e7", # iris
"#ebbcba", # rose
"#f6c177", # gold
"#a3be8c", # pine-ish green
"#ea9a97", # love
)
#: Rendered width of :func:`_format_timestamp` output.
TIMESTAMP_WIDTH = len("2026-01-01T00:00:00.000Z")
def _format_timestamp(value: Any) -> str:
"""Compact UTC timestamp: ``YYYY-MM-DDTHH:MM:SS.mmmZ``.
Offsets are converted to UTC; naive values are assumed UTC. Unparseable
values pass through verbatim.
"""
if value is None:
return ""
if isinstance(value, datetime):
parsed = value
else:
try:
parsed = datetime.fromisoformat(str(value).strip())
except ValueError:
return str(value).strip()
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc)
return f"{parsed:%Y-%m-%dT%H:%M:%S}.{parsed.microsecond // 1000:03d}Z"
def print_transcript(
messages: list[dict[str, Any]],
*,
session_id: str,
total: int | None = None,
page: int | None = None,
pages: int | None = None,
show_ids: bool = False,
next_page_hint: str | None = None,
) -> None:
"""Render a session transcript as a row-delimited table, or JSON.
Each message dict must have ``peer_id``, ``content``, ``created_at``;
``id`` is optional and only shown when ``show_ids`` is set.
``next_page_hint`` is printed below the table when given.
"""
if use_json():
print_json(messages)
return
shown = len(messages)
parts = [f"session {session_id}"]
if page is not None and pages is not None:
parts.append(f"page {page}/{pages}")
elif page is not None:
parts.append(f"page {page}")
if total is not None and shown != total:
parts.append(f"showing {shown} of {total}")
else:
parts.append(f"{shown} message{'s' if shown != 1 else ''}")
title = " · ".join(parts)
if not messages:
stdout_console.print(f"[dim]── {title} ──[/dim]")
stdout_console.print("[dim] (empty)[/dim]")
return
table = Table(
title=title,
show_header=True,
header_style="bold",
show_lines=True, # delimiters between rows
expand=True,
pad_edge=False,
)
# time is fixed-width ISO-UTC; ids and peers wrap rather than truncate;
# content takes the rest.
table.add_column("time", style="dim", no_wrap=True, width=TIMESTAMP_WIDTH)
if show_ids:
table.add_column("id", style="dim", no_wrap=True)
table.add_column("peer", overflow="fold", max_width=24)
table.add_column("content", overflow="fold", ratio=1, min_width=40)
peer_color: dict[str, str] = {}
for msg in messages:
peer = str(msg.get("peer_id") or "?")
if peer not in peer_color:
peer_color[peer] = _PEER_COLORS[len(peer_color) % len(_PEER_COLORS)]
# Text, not Markdown or console markup: content renders verbatim.
row: list[Any] = [_format_timestamp(msg.get("created_at"))]
if show_ids:
row.append(Text(str(msg.get("id") or "")))
row.append(Text(peer, style=f"bold {peer_color[peer]}"))
row.append(Text(str(msg.get("content") or "")))
table.add_row(*row)
stdout_console.print(table)
if next_page_hint:
status(f"more: {next_page_hint}")

View File

@ -9,11 +9,13 @@ from __future__ import annotations
import json
import os
from contextlib import ExitStack, contextmanager
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from honcho_cli.commands.session import _next_page_command
from honcho_cli.main import app
@ -34,6 +36,62 @@ def runner():
return CliRunner()
# --------------------------------------------------------------------------- #
# Helpers for `session view` — a fake SDK message, page, and Session
def _view_msg(i: int) -> MagicMock:
return MagicMock(
id=f"m{i}",
peer_id="alice" if i % 2 == 0 else "bob",
content=f"msg-{i}",
token_count=i,
metadata={},
created_at=f"2026-01-01T00:00:00.{i:03d}Z",
)
def _fake_page(
items: list,
*,
total: int | None = None,
page: int | None = None,
pages: int | None = None,
has_next: bool = False,
next_page: MagicMock | None = None,
) -> MagicMock:
"""A stand-in for the SDK's ``SyncPage`` with explicit (non-mock) metadata."""
fake = MagicMock()
fake.items = items
fake.total = total
fake.page = page
fake.pages = pages
fake.has_next_page.return_value = has_next
fake.get_next_page.return_value = next_page
return fake
def _fake_session(page: MagicMock) -> MagicMock:
session = MagicMock()
session.messages.return_value = page
return session
def _patch_view(session: MagicMock, *, peer_id: str = ""):
"""Patch `session view`'s client + read-only Session construction."""
client = MagicMock()
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id=peer_id)
return _nested(
patch("honcho_cli.commands.session.get_client", return_value=(client, config)),
patch("honcho_cli.commands.session.Session", return_value=session),
)
@contextmanager
def _nested(*managers):
with ExitStack() as stack:
yield [stack.enter_context(m) for m in managers]
# --------------------------------------------------------------------------- #
# 1. `honcho init` end-to-end
@ -169,6 +227,233 @@ class TestJsonContract:
"created_at": "2026-01-01T00:00:00Z",
}
@pytest.mark.parametrize("last", ["0", "-5"])
def test_message_list_rejects_non_positive_last(self, cfg, runner, last):
"""Non-positive --last silently returned an empty list via slice semantics."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.message.get_client", return_value=(MagicMock(), config)) as get_client:
result = runner.invoke(
app,
["message", "list", "sess1", "--last", last, "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
get_client.assert_not_called()
def test_session_view_json_is_chronological_window(self, cfg, runner):
"""`session view` returns the most recent N messages oldest→newest by default."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Server returns newest-first when reverse=True (m4, m3, m2, m1, m0).
page = _fake_page([_view_msg(i) for i in range(4, -1, -1)], total=5)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--last", "3", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
# Most recent 3 (m4,m3,m2) flipped to chronological: m2, m3, m4.
assert [m["id"] for m in payload] == ["m2", "m3", "m4"]
assert [m["content"] for m in payload] == ["msg-2", "msg-3", "msg-4"]
session.messages.assert_called_once()
assert session.messages.call_args.kwargs["reverse"] is True
def test_session_view_rejects_all_with_last(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)):
result = runner.invoke(
app,
["session", "view", "sess1", "--all", "--last", "10", "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
def test_session_view_page_fetches_exact_server_page(self, cfg, runner):
"""`--page N --size M` hits the API page directly (oldest-first)."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Page 2 contents, already chronological.
page = _fake_page(
[_view_msg(i) for i in (50, 51, 52)],
total=120,
page=2,
pages=3,
has_next=True,
)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "2", "--size", "50", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert [m["id"] for m in payload] == ["m50", "m51", "m52"]
session.messages.assert_called_once_with(
filters=None,
page=2,
size=50,
reverse=False,
)
def test_session_view_page_with_reverse_pages_from_newest(self, cfg, runner):
"""`--reverse --page N` pages from the newest end, not the oldest one flipped."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
page = _fake_page([_view_msg(i) for i in (9, 8, 7)], total=30, page=1, pages=10)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "1", "--reverse", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
assert session.messages.call_args.kwargs["reverse"] is True
# Server order is preserved: no local flip on top of a reversed fetch.
assert [m["id"] for m in json.loads(result.stdout)] == ["m9", "m8", "m7"]
def test_session_view_rejects_page_with_last(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "2", "--last", "10", "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
@pytest.mark.parametrize(
"args",
[
["--size", "10"], # --size requires --page
["--page", "1", "--size", "500"], # over the server's 100 ceiling
["--page", "0"],
["--last", "0"],
],
)
def test_session_view_rejects_bad_flags_before_any_api_call(self, cfg, runner, args):
"""Flag validation runs before the client is built, so nothing reaches the API."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
with patch("honcho_cli.commands.session.get_client") as get_client:
result = runner.invoke(app, ["session", "view", "sess1", *args, "-w", "ws1"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
get_client.assert_not_called()
def test_session_view_does_not_create_the_session(self, cfg, runner):
"""`view` is read-only: it must not use the get-or-create client.session()."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = _fake_session(_fake_page([_view_msg(1)], total=1))
client = MagicMock()
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(client, config)), \
patch("honcho_cli.commands.session.Session", return_value=session) as session_cls:
result = runner.invoke(app, ["session", "view", "sess1", "-w", "ws1", "--json"])
assert result.exit_code == 0, result.stderr
client.session.assert_not_called()
session_cls.assert_called_once_with("sess1", client)
@pytest.mark.parametrize(
("kwargs", "expected"),
[
(
{},
"honcho session view s1 --page 2 --size 50",
),
(
{"reverse": True, "show_ids": True},
"honcho session view s1 --page 2 --size 50 --reverse --ids",
),
(
{"workspace": "ws2", "peer": "alice"},
"honcho session view s1 --page 2 --size 50 -w ws2 -p alice",
),
],
)
def test_next_page_command_carries_the_invocation_scope(self, kwargs, expected):
"""A copied hint must land on the same workspace, peer, and ordering."""
opts = {"reverse": False, "show_ids": False, "workspace": None, "peer": None, **kwargs}
assert _next_page_command("s1", 2, 50, **opts) == expected
@pytest.mark.parametrize(
("session_id", "workspace", "expected_fragment"),
[
("has space", None, "'has space'"),
("a;rm -rf x", None, "'a;rm -rf x'"),
("s1", "ws$(id)", "'ws$(id)'"),
("s1", "ws|tee", "'ws|tee'"),
],
)
def test_next_page_command_shell_quotes_identifiers(
self, session_id, workspace, expected_fragment
):
"""IDs only reject ?#%/\\ and control chars, so spaces and metacharacters reach here."""
hint = _next_page_command(
session_id,
2,
50,
reverse=False,
show_ids=False,
workspace=workspace,
peer=None,
)
assert expected_fragment in hint
def test_session_view_hint_carries_group_level_scope(self, cfg, runner):
"""-w/-p also parse at group level, where the command-level params are None."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = _fake_session(_fake_page([_view_msg(1)], total=10, page=1, pages=5))
with _patch_view(session), patch("honcho_cli.output.use_json", return_value=False):
result = runner.invoke(
app,
["session", "-w", "ws2", "-p", "alice", "view", "sess1", "--page", "1"],
)
assert result.exit_code == 0, result.stderr
assert "-w ws2" in result.stderr
assert "-p alice" in result.stderr
def test_session_view_last_walks_pages_past_the_page_cap(self, cfg, runner):
"""`--last N` above the 100-item server cap keeps walking instead of truncating."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Newest-first pages of 100: m149..m50, then m49..m0.
second = _fake_page([_view_msg(i) for i in range(49, -1, -1)], total=150)
first = _fake_page(
[_view_msg(i) for i in range(149, 49, -1)],
total=150,
has_next=True,
next_page=second,
)
session = _fake_session(first)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--last", "120", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert len(payload) == 120
# Oldest of the 120-message tail first, newest last.
assert payload[0]["id"] == "m30"
assert payload[-1]["id"] == "m149"
assert session.messages.call_args.kwargs["size"] == 100
# --------------------------------------------------------------------------- #
# 4. Exit codes on error

View File

@ -0,0 +1,139 @@
"""Transcript rendering: timestamp normalization and content fidelity.
`session view` is a debugging surface, so the human-mode table must show what
was actually stored no Markdown reflow, no truncated identifiers.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from honcho_cli import output
from honcho_cli.output import _format_timestamp, print_transcript
@pytest.fixture
def render(monkeypatch, capsys):
"""Render a transcript in human mode at a fixed width and return stdout."""
monkeypatch.setattr(output, "is_tty", lambda: True)
monkeypatch.setattr(output, "_force_json", False)
def _render(messages, width: int = 120, **kwargs):
monkeypatch.setattr(output, "stdout_console", output.Console(width=width, no_color=True))
print_transcript(messages, **kwargs)
return capsys.readouterr().out
return _render
def _msg(content: str = "hi", **overrides) -> dict:
return {
"id": "V1StGXR8_Z5jdHi6B-myT",
"peer_id": "alice",
"content": content,
"created_at": "2026-01-01T00:00:00Z",
**overrides,
}
class TestFormatTimestamp:
@pytest.mark.parametrize(
("value", "expected"),
[
("2026-01-01T00:00:00Z", "2026-01-01T00:00:00.000Z"),
("2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00.000Z"),
("2026-01-01 00:00:00+00:00", "2026-01-01T00:00:00.000Z"),
("2026-01-01 00:00:00", "2026-01-01T00:00:00.000Z"),
("2026-01-01T00:00:00.080000Z", "2026-01-01T00:00:00.080Z"),
],
)
def test_normalizes_utc_shapes(self, value, expected):
assert _format_timestamp(value) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
("2026-01-01T14:30:00-05:00", "2026-01-01T19:30:00.000Z"),
("2026-01-01T14:30:00+02:00", "2026-01-01T12:30:00.000Z"),
],
)
def test_converts_offsets_instead_of_relabelling_them(self, value, expected):
"""An offset must be converted to UTC, not dropped and stamped `Z`."""
assert _format_timestamp(value) == expected
def test_keeps_sub_second_precision(self):
"""Messages inside the same second must stay distinguishable."""
a = _format_timestamp("2026-01-01T00:00:03.000Z")
b = _format_timestamp("2026-01-01T00:00:03.080Z")
assert a != b
assert (a, b) == ("2026-01-01T00:00:03.000Z", "2026-01-01T00:00:03.080Z")
def test_accepts_datetime_objects(self):
value = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
assert _format_timestamp(value) == "2026-01-01T12:00:00.000Z"
@pytest.mark.parametrize("value", [None, ""])
def test_empty_values_render_blank(self, value):
assert _format_timestamp(value) == ""
def test_unparseable_values_pass_through(self):
assert _format_timestamp("not-a-date") == "not-a-date"
def test_output_matches_the_declared_column_width(self):
assert len(_format_timestamp("2026-01-01T00:00:00Z")) == output.TIMESTAMP_WIDTH
class TestTranscriptFidelity:
def test_newlines_are_not_reflowed_into_a_paragraph(self, render):
out = render([_msg("line one\nline two\nline three")], session_id="s1")
assert "line one line two line three" not in out
for line in ("line one", "line two", "line three"):
assert line in out
def test_tagged_content_is_not_stripped(self, render):
"""Agent transcripts are full of `<thinking>`-style tags; they must survive."""
out = render([_msg("<thinking>reasoning</thinking> answer")], session_id="s1")
assert "<thinking>" in out
assert "</thinking>" in out
def test_console_markup_is_not_interpreted(self, render):
out = render([_msg("literal [bold]not markup[/bold] text")], session_id="s1")
assert "[bold]" in out
def test_ids_are_shown_in_full(self, render):
"""A displayed ID must be usable with `honcho message get`."""
out = render([_msg()], session_id="s1", show_ids=True)
assert "V1StGXR8_Z5jdHi6B-myT" in out
assert "" not in out
def test_long_peer_ids_stay_distinguishable(self, render):
out = render(
[
_msg(peer_id="user_1234567890abcdef"),
_msg(peer_id="user_1234567890abcXYZ"),
],
session_id="s1",
)
assert "abcdef" in out
assert "abcXYZ" in out
def test_empty_transcript_reports_the_session(self, render):
out = render([], session_id="s1")
assert "s1" in out
assert "(empty)" in out
class TestNextPageHint:
def test_given_hint_is_printed(self, render, monkeypatch):
printed: list[str] = []
monkeypatch.setattr(output, "status", printed.append)
render([_msg()], session_id="s1", page=1, pages=3, next_page_hint="honcho ... --page 2")
assert printed == ["more: honcho ... --page 2"]
def test_no_hint_when_none_given(self, render, monkeypatch):
printed: list[str] = []
monkeypatch.setattr(output, "status", printed.append)
render([_msg()], session_id="s1", page=3, pages=3)
assert printed == []

View File

@ -20,7 +20,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
- `honcho config` — CLI configuration
- `honcho workspace` — inspect, delete, search
- `honcho peer` — inspect, card, chat, search
- `honcho session` — inspect, messages, context, summaries
- `honcho session` — inspect, view (transcript), context, summaries
- `honcho message` — list and get
- `honcho conclusion` — list, search, create, delete
@ -61,6 +61,8 @@ honcho conclusion search "topic" --observer <peer_id> --json
```bash
honcho session inspect <session_id> --json
honcho session view <session_id> --last 20 --json
honcho session view <session_id> --page 2 --size 50 --json
honcho message list <session_id> --last 20 --json
honcho session context <session_id> --json
honcho session summaries <session_id> --json