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:
parent
bd6163d9e3
commit
0d57df430e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue