mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
cli/history: group /history by turn
/history counted raw message rows, so /history 1 printed whatever row happened to be last (usually a tool result in a tool-heavy session) and "one row means full body" was a proxy for "show me that one thing". A turn is what a human reviews: /history now prints the last turn's two ends (the user message and the model's answer, markdown for the latter), -v prints every row of the selected turn(s), -vv prints full bodies plus the local system row, and --preview collapses every row to one line. N addresses turn N, last [N] the last N turns, --message N a single row. Numbering is a display ordinal over conversation rows (local-only rows print as local) and deliberately never a database row number - seq is an implementation detail that shifts with /clear. Grouping and rendering live in the new cli/transcript.py.
This commit is contained in:
+98
-161
@@ -2,23 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from typing import TYPE_CHECKING, Any, cast, override
|
from typing import TYPE_CHECKING, Any, Literal, cast, override
|
||||||
|
|
||||||
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||||
import click
|
import click
|
||||||
from click.shell_completion import CompletionItem
|
from click.shell_completion import CompletionItem
|
||||||
from msgspec import UNSET
|
|
||||||
|
|
||||||
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
|
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
|
||||||
from plyngent.cli.retry import retry_pending_with_retries
|
from plyngent.cli.retry import retry_pending_with_retries
|
||||||
from plyngent.cli.selection import select_model, select_provider
|
from plyngent.cli.selection import select_model, select_provider
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
|
||||||
AssistantChatMessage,
|
|
||||||
AssistantFunctionToolCall,
|
|
||||||
DeveloperChatMessage,
|
|
||||||
SystemChatMessage,
|
|
||||||
UserChatMessage,
|
|
||||||
)
|
|
||||||
from plyngent.runtime import ProviderNotSupportedError
|
from plyngent.runtime import ProviderNotSupportedError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,10 +18,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from plyngent.agent import ToolRegistry
|
from plyngent.agent import ToolRegistry
|
||||||
from plyngent.cli.state import ReplState, YoloMode
|
from plyngent.cli.state import ReplState, YoloMode
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
|
||||||
|
|
||||||
_DEFAULT_HISTORY_LINES = 20
|
|
||||||
_CONTENT_PREVIEW = 200
|
|
||||||
_COMPACT_PREVIEW = 400
|
_COMPACT_PREVIEW = 400
|
||||||
_ON_OFF_CHOICES = ("on", "off")
|
_ON_OFF_CHOICES = ("on", "off")
|
||||||
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||||
@@ -49,33 +38,77 @@ _TODO_ACTION_CHOICES = (
|
|||||||
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||||
|
|
||||||
|
|
||||||
class HistoryLimitType(click.ParamType[int]):
|
class HistoryTargetType(click.ParamType[int | Literal["last"]]):
|
||||||
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
"""``N`` (turn number >= 1) or the keyword ``last`` (most recent turn)."""
|
||||||
|
|
||||||
name: str = "history_limit"
|
name: str = "history_target"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
def convert(
|
||||||
if isinstance(value, int):
|
self,
|
||||||
if value < 1:
|
value: object,
|
||||||
self.fail("must be >= 1", param, ctx)
|
param: click.Parameter | None,
|
||||||
return value
|
ctx: click.Context | None,
|
||||||
text = str(value).strip().lower()
|
) -> int | Literal["last"]:
|
||||||
|
text = value.strip().lower() if isinstance(value, str) else str(value)
|
||||||
if text == "last":
|
if text == "last":
|
||||||
return 1
|
return "last"
|
||||||
try:
|
try:
|
||||||
n = int(text, 10)
|
n = int(text, 10)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.fail("expected an integer N or 'last'", param, ctx)
|
self.fail("expected a turn number N or 'last'", param, ctx)
|
||||||
if n < 1:
|
if n < 1:
|
||||||
self.fail("must be >= 1", param, ctx)
|
self.fail("must be >= 1", param, ctx)
|
||||||
return n
|
return n
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
del ctx, param
|
del param
|
||||||
tokens = ("last", "1", "5", "10", "20")
|
tokens = ["last"]
|
||||||
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
|
state = _repl_state(ctx)
|
||||||
|
if state is not None:
|
||||||
|
from plyngent.cli.transcript import build_transcript
|
||||||
|
|
||||||
|
turns = build_transcript(state.agent.messages).turns
|
||||||
|
# Recent turn numbers are the ones a human is likely to jump back to.
|
||||||
|
tokens += [str(turn.ordinal) for turn in turns[-5:]]
|
||||||
|
token = incomplete.strip().lower()
|
||||||
|
return [CompletionItem(item) for item in tokens if item.startswith(token)]
|
||||||
|
|
||||||
|
|
||||||
|
HISTORY_TARGET = HistoryTargetType()
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryRowNumberType(click.ParamType[int]):
|
||||||
|
"""One-based display number of a transcript row (``/history --message N``)."""
|
||||||
|
|
||||||
|
name: str = "history_message"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||||
|
try:
|
||||||
|
n = int(str(value), 10)
|
||||||
|
except ValueError:
|
||||||
|
self.fail("expected a message number N", param, ctx)
|
||||||
|
if n < 1:
|
||||||
|
self.fail("must be >= 1", param, ctx)
|
||||||
|
return n
|
||||||
|
|
||||||
|
@override
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
del param
|
||||||
|
state = _repl_state(ctx)
|
||||||
|
if state is None:
|
||||||
|
return []
|
||||||
|
from plyngent.cli.transcript import build_transcript
|
||||||
|
|
||||||
|
transcript = build_transcript(state.agent.messages)
|
||||||
|
token = incomplete.strip()
|
||||||
|
recent = range(max(1, transcript.messages - 4), transcript.messages + 1)
|
||||||
|
return [CompletionItem(str(n)) for n in recent if str(n).startswith(token)]
|
||||||
|
|
||||||
|
|
||||||
|
HISTORY_ROW_NUMBER = HistoryRowNumberType()
|
||||||
|
|
||||||
|
|
||||||
HELP_FOOTER = (
|
HELP_FOOTER = (
|
||||||
@@ -1367,65 +1400,61 @@ def rounds_cmd(state: ReplState, n: int | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("history")
|
@slash.command("history")
|
||||||
@click.argument("n", required=False, type=HistoryLimitType())
|
@click.argument("target", required=False, type=HISTORY_TARGET)
|
||||||
|
@click.argument("count", required=False, type=click.IntRange(min=1), metavar="[N]")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--full",
|
"--message",
|
||||||
"mode_full",
|
"message_no",
|
||||||
is_flag=True,
|
type=HISTORY_ROW_NUMBER,
|
||||||
default=False,
|
default=None,
|
||||||
help="Print full message bodies (markdown for assistant when TTY).",
|
metavar="N",
|
||||||
|
help="Print message N (display number) instead of a turn.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
"verbose",
|
||||||
|
count=True,
|
||||||
|
help="Print every row of the turn(s); repeat (-vv) for full bodies.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--preview",
|
"--preview",
|
||||||
"mode_preview",
|
"mode_preview",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
help="Force short previews even for a single message.",
|
help="One-line preview for every printed row.",
|
||||||
)
|
)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def history_cmd(
|
def history_cmd(
|
||||||
state: ReplState,
|
state: ReplState,
|
||||||
n: int | None,
|
target: int | Literal["last"] | None,
|
||||||
|
count: int | None,
|
||||||
*,
|
*,
|
||||||
mode_full: bool,
|
message_no: int | None,
|
||||||
|
verbose: int,
|
||||||
mode_preview: bool,
|
mode_preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show recent messages (default: last 20 as short previews).
|
"""Show conversation history, grouped by turn.
|
||||||
|
|
||||||
``/history last`` or ``/history 1`` shows the last message in full with Rich
|
``/history`` — the last turn: your message and the model's final answer
|
||||||
markdown when available. Use ``--full`` for multiple messages in full;
|
``/history N`` — turn N (numbered from the first turn of this session)
|
||||||
``--preview`` forces the short form even for a single message.
|
``/history last [N]`` — the last N turns (default 1)
|
||||||
|
``/history --message N`` — one message (display number, not a DB row)
|
||||||
|
``-v`` adds every intermediate row (tool calls/results, notices); ``-vv``
|
||||||
|
prints full bodies for all of them; ``--preview`` collapses to one line each.
|
||||||
"""
|
"""
|
||||||
if mode_full and mode_preview:
|
from plyngent.cli import transcript as transcript_mod
|
||||||
msg = "use only one of --full / --preview"
|
|
||||||
raise click.UsageError(msg)
|
transcript_mod.echo_history(
|
||||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
state.agent.messages,
|
||||||
messages = state.agent.messages
|
session_id=state.session_id,
|
||||||
if not messages:
|
pending_retry_text=state.agent.pending_retry_text,
|
||||||
click.echo("(no messages in this session)")
|
target=target,
|
||||||
return
|
count=count,
|
||||||
start = max(0, len(messages) - limit)
|
message_no=message_no,
|
||||||
slice_msgs = messages[start:]
|
verbose=verbose,
|
||||||
# Full: --full, or single-message window (last / 1) unless --preview.
|
preview=mode_preview,
|
||||||
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
)
|
||||||
mode_tag = "full" if full else "preview"
|
|
||||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
|
||||||
for offset, message in enumerate(slice_msgs):
|
|
||||||
idx = start + offset
|
|
||||||
if full:
|
|
||||||
_print_history_message_full(idx, message)
|
|
||||||
else:
|
|
||||||
click.echo(_format_history_message(idx, message))
|
|
||||||
if state.agent.pending_retry_text is not None:
|
|
||||||
pending = state.agent.pending_retry_text
|
|
||||||
if full:
|
|
||||||
click.secho("(pending retry) user:", fg="yellow")
|
|
||||||
click.echo(pending)
|
|
||||||
else:
|
|
||||||
click.secho(
|
|
||||||
f"(pending retry) user: {_preview_content(pending)}",
|
|
||||||
fg="yellow",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@slash.command("todos")
|
@slash.command("todos")
|
||||||
@@ -1519,98 +1548,6 @@ def retry_cmd(state: ReplState) -> None:
|
|||||||
_ = _await(retry_pending_with_retries(state.agent))
|
_ = _await(retry_pending_with_retries(state.agent))
|
||||||
|
|
||||||
|
|
||||||
def _preview_content(text: str | None) -> str:
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
if len(text) <= _CONTENT_PREVIEW:
|
|
||||||
return text
|
|
||||||
return text[:_CONTENT_PREVIEW] + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
|
|
||||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
|
||||||
|
|
||||||
click.secho(f"{index}. assistant:", fg="cyan")
|
|
||||||
content = message.content
|
|
||||||
has_text = isinstance(content, str) and content.strip()
|
|
||||||
if has_text and isinstance(content, str):
|
|
||||||
if markdown_render_available():
|
|
||||||
print_markdown(content, label="")
|
|
||||||
else:
|
|
||||||
click.echo(content)
|
|
||||||
reasoning = message.reasoning_content
|
|
||||||
has_reason = isinstance(reasoning, str) and reasoning.strip()
|
|
||||||
if has_reason and isinstance(reasoning, str):
|
|
||||||
click.secho("reasoning:", fg="bright_black")
|
|
||||||
click.echo(reasoning)
|
|
||||||
tool_calls = message.tool_calls
|
|
||||||
has_tools = tool_calls is not UNSET and bool(tool_calls)
|
|
||||||
if has_tools and tool_calls is not UNSET:
|
|
||||||
for call in tool_calls:
|
|
||||||
if isinstance(call, AssistantFunctionToolCall):
|
|
||||||
click.secho(
|
|
||||||
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
|
|
||||||
fg="yellow",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
click.secho(f" tool_call custom id={call.id}", fg="yellow")
|
|
||||||
if not (has_text or has_reason or has_tools):
|
|
||||||
click.echo("(empty)")
|
|
||||||
click.echo()
|
|
||||||
|
|
||||||
|
|
||||||
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
|
|
||||||
"""Print one history message with full body; Rich markdown for assistant text."""
|
|
||||||
if isinstance(message, UserChatMessage):
|
|
||||||
click.secho(f"{index}. user:", fg="green")
|
|
||||||
click.echo(message.content or "")
|
|
||||||
click.echo()
|
|
||||||
return
|
|
||||||
if isinstance(message, DeveloperChatMessage):
|
|
||||||
click.secho(f"{index}. developer:", fg="blue")
|
|
||||||
click.echo(message.content or "")
|
|
||||||
click.echo()
|
|
||||||
return
|
|
||||||
if isinstance(message, SystemChatMessage):
|
|
||||||
click.secho(f"{index}. system:", fg="bright_black")
|
|
||||||
click.echo(message.content or "")
|
|
||||||
click.echo()
|
|
||||||
return
|
|
||||||
if isinstance(message, AssistantChatMessage):
|
|
||||||
_print_history_assistant_full(index, message)
|
|
||||||
return
|
|
||||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
|
||||||
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
|
|
||||||
click.echo(message.content or "")
|
|
||||||
click.echo()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
|
||||||
if isinstance(message, UserChatMessage):
|
|
||||||
return f"{index}. user: {_preview_content(message.content)}"
|
|
||||||
if isinstance(message, DeveloperChatMessage):
|
|
||||||
return f"{index}. developer: {_preview_content(message.content)}"
|
|
||||||
if isinstance(message, SystemChatMessage):
|
|
||||||
return f"{index}. system: {_preview_content(message.content)}"
|
|
||||||
if isinstance(message, AssistantChatMessage):
|
|
||||||
parts: list[str] = []
|
|
||||||
if isinstance(message.content, str) and message.content:
|
|
||||||
parts.append(_preview_content(message.content))
|
|
||||||
tool_calls = message.tool_calls
|
|
||||||
if tool_calls is not UNSET and tool_calls:
|
|
||||||
names: list[str] = []
|
|
||||||
for call in tool_calls:
|
|
||||||
if isinstance(call, AssistantFunctionToolCall):
|
|
||||||
names.append(call.function.name)
|
|
||||||
else:
|
|
||||||
names.append("custom")
|
|
||||||
parts.append(f"tool_calls=[{', '.join(names)}]")
|
|
||||||
body = " ".join(parts) if parts else "(empty)"
|
|
||||||
return f"{index}. assistant: {body}"
|
|
||||||
# ToolChatMessage
|
|
||||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||||
"""Sync Click entrypoint; may call awaitlet() for async work."""
|
"""Sync Click entrypoint; may call awaitlet() for async work."""
|
||||||
# standalone_mode=False → UsageError/ClickException instead of SystemExit.
|
# standalone_mode=False → UsageError/ClickException instead of SystemExit.
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Turn-oriented transcript grouping/rendering for ``/history``.
|
||||||
|
|
||||||
|
The REPL transcript is a flat list of chat messages, but what a human reviews is
|
||||||
|
a *turn*: their message, the tool-loop rounds it triggered, and the model's final
|
||||||
|
answer. This module groups the list into numbered rows and turns, and renders
|
||||||
|
either the two ends of a turn (default), every row of it (``-v``), or full bodies
|
||||||
|
for every printed row (``-vv``).
|
||||||
|
|
||||||
|
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
||||||
|
order the session holds them): it is what ``/history`` prints and what
|
||||||
|
``/history --message N`` addresses. Database row numbers are deliberately never
|
||||||
|
shown or used — they are an implementation detail that shifts with ``/clear``.
|
||||||
|
Local-only rows (the injected system prompt) have no number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
|
import click
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
PREVIEW_CHARS = 200
|
||||||
|
# ``-v`` expands a turn's rows; ``-vv`` also prints their full bodies.
|
||||||
|
MAX_VERBOSE = 2
|
||||||
|
|
||||||
|
# Detail policy: ``mixed`` = a turn's final assistant answer in full, everything
|
||||||
|
# else on one line; ``full``/``preview`` force the extremes.
|
||||||
|
type Detail = Literal["mixed", "full", "preview"]
|
||||||
|
# ``/history N`` addresses a turn by number; ``last`` counts from the end.
|
||||||
|
type TurnTarget = int | Literal["last"]
|
||||||
|
|
||||||
|
|
||||||
|
def _content(message: AnyChatMessage) -> str:
|
||||||
|
"""Message body as text (``content`` may be UNSET/None for assistants)."""
|
||||||
|
return message.content if isinstance(message.content, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def one_line(text: str | None, *, limit: int = PREVIEW_CHARS) -> str:
|
||||||
|
"""Collapse *text* to one line: first line, truncated, may add ``(+N lines)``."""
|
||||||
|
body = (text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return ""
|
||||||
|
lines = body.splitlines()
|
||||||
|
head = lines[0].strip()
|
||||||
|
if len(head) > limit:
|
||||||
|
head = head[:limit] + "…"
|
||||||
|
if len(lines) > 1:
|
||||||
|
head += f" (+{len(lines) - 1} lines)"
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
def _role(message: AnyChatMessage) -> str:
|
||||||
|
if isinstance(message, UserChatMessage):
|
||||||
|
return "user"
|
||||||
|
if isinstance(message, AssistantChatMessage):
|
||||||
|
return "assistant"
|
||||||
|
if isinstance(message, DeveloperChatMessage):
|
||||||
|
return "developer"
|
||||||
|
if isinstance(message, SystemChatMessage):
|
||||||
|
return "system"
|
||||||
|
assert isinstance(message, ToolChatMessage)
|
||||||
|
return f"tool({message.tool_call_id})"
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_COLOR: dict[str, str] = {
|
||||||
|
"user": "green",
|
||||||
|
"assistant": "cyan",
|
||||||
|
"developer": "blue",
|
||||||
|
"system": "bright_black",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _role_color(message: AnyChatMessage) -> str:
|
||||||
|
return "magenta" if isinstance(message, ToolChatMessage) else _ROLE_COLOR[_role(message)]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call_names(message: AssistantChatMessage) -> list[str]:
|
||||||
|
tool_calls = message.tool_calls
|
||||||
|
if tool_calls is UNSET or not tool_calls:
|
||||||
|
return []
|
||||||
|
return [call.function.name if isinstance(call, AssistantFunctionToolCall) else "custom" for call in tool_calls]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Row:
|
||||||
|
"""One transcript row; ``number`` is the display ordinal (None = local-only)."""
|
||||||
|
|
||||||
|
message: AnyChatMessage
|
||||||
|
number: int | None
|
||||||
|
|
||||||
|
def prefix(self) -> str:
|
||||||
|
"""``12. user:`` — always styled (local rows keep the role colour)."""
|
||||||
|
label = "local" if self.number is None else str(self.number)
|
||||||
|
return str(click.style(f"{label}. {_role(self.message)}:", fg=_role_color(self.message)))
|
||||||
|
|
||||||
|
def preview(self) -> str:
|
||||||
|
message = self.message
|
||||||
|
names = _tool_call_names(message) if isinstance(message, AssistantChatMessage) else []
|
||||||
|
suffix = f" tool_calls=[{', '.join(names)}]" if names else ""
|
||||||
|
text = one_line(_content(message))
|
||||||
|
if not text and isinstance(message, AssistantChatMessage) and not names:
|
||||||
|
text = "(empty)"
|
||||||
|
return f"{text}{suffix}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Turn:
|
||||||
|
"""A user turn: the user row plus the rounds it triggered."""
|
||||||
|
|
||||||
|
ordinal: int
|
||||||
|
rows: tuple[Row, ...]
|
||||||
|
incomplete: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self) -> Row:
|
||||||
|
return self.rows[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last(self) -> Row:
|
||||||
|
return self.rows[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rounds(self) -> int:
|
||||||
|
"""Model rounds that asked for tools (one tool batch each)."""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for row in self.rows
|
||||||
|
if isinstance(row.message, AssistantChatMessage) and bool(_tool_call_names(row.message))
|
||||||
|
)
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
numbers = [row.number for row in self.rows if row.number is not None]
|
||||||
|
parts = [f"msgs {numbers[0]}-{numbers[-1]}" if numbers else "local rows"]
|
||||||
|
if self.rounds:
|
||||||
|
parts.append(f"rounds={self.rounds}")
|
||||||
|
text = f"turn {self.ordinal} ({', '.join(parts)})"
|
||||||
|
if self.incomplete:
|
||||||
|
text += " [incomplete — /retry continues]"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Transcript:
|
||||||
|
"""Numbered rows grouped by turn (plus rows that precede the first turn)."""
|
||||||
|
|
||||||
|
prelude: tuple[Row, ...]
|
||||||
|
turns: tuple[Turn, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self) -> int:
|
||||||
|
"""Numbered rows (what ``/history --message N`` can address)."""
|
||||||
|
return sum(1 for turn in self.turns for row in turn.rows if row.number is not None) + sum(
|
||||||
|
1 for row in self.prelude if row.number is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def turn(self, ordinal: int) -> Turn | None:
|
||||||
|
return next((turn for turn in self.turns if turn.ordinal == ordinal), None)
|
||||||
|
|
||||||
|
def row(self, number: int) -> Row | None:
|
||||||
|
for row in self.prelude:
|
||||||
|
if row.number == number:
|
||||||
|
return row
|
||||||
|
for turn in self.turns:
|
||||||
|
for row in turn.rows:
|
||||||
|
if row.number == number:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
def turn_of(self, number: int) -> Turn | None:
|
||||||
|
return next((turn for turn in self.turns if any(row.number == number for row in turn.rows)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text: str | None = None) -> Transcript:
|
||||||
|
"""Group *messages* into numbered rows and turns.
|
||||||
|
|
||||||
|
A turn starts at a user message and runs until the next one, so tool results,
|
||||||
|
developer notices/checkpoints and synthetic todo nags stay inside the turn
|
||||||
|
they belong to. Rows before the first user message (the injected system
|
||||||
|
prompt, a compact seed summary, a resume notice) form the prelude.
|
||||||
|
|
||||||
|
``pending_retry_text`` marks the last turn as incomplete when it matches that
|
||||||
|
turn's user message (``ChatAgent.pending_retry_text``).
|
||||||
|
"""
|
||||||
|
rows: list[Row] = []
|
||||||
|
number = 0
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, SystemChatMessage):
|
||||||
|
# The system prompt is injected locally and never persisted.
|
||||||
|
rows.append(Row(message=message, number=None))
|
||||||
|
continue
|
||||||
|
number += 1
|
||||||
|
rows.append(Row(message=message, number=number))
|
||||||
|
|
||||||
|
prelude: list[Row] = []
|
||||||
|
turns: list[Turn] = []
|
||||||
|
current: list[Row] = []
|
||||||
|
for row in rows:
|
||||||
|
if isinstance(row.message, UserChatMessage):
|
||||||
|
if current:
|
||||||
|
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||||
|
current = [row]
|
||||||
|
elif current:
|
||||||
|
current.append(row)
|
||||||
|
else:
|
||||||
|
prelude.append(row)
|
||||||
|
if current:
|
||||||
|
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||||
|
|
||||||
|
if turns and pending_retry_text is not None:
|
||||||
|
user = turns[-1].user.message
|
||||||
|
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
||||||
|
turns[-1] = replace(turns[-1], incomplete=True)
|
||||||
|
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||||
|
|
||||||
|
|
||||||
|
def echo_row(row: Row, *, detail: Detail) -> None:
|
||||||
|
"""Print one row: a one-line preview, or the full body for ``full``/``mixed``."""
|
||||||
|
if detail == "preview":
|
||||||
|
click.echo(f"{row.prefix()} {row.preview()}")
|
||||||
|
return
|
||||||
|
if isinstance(row.message, AssistantChatMessage):
|
||||||
|
_echo_assistant(row, reasoning=detail == "full")
|
||||||
|
return
|
||||||
|
click.echo(row.prefix())
|
||||||
|
click.echo(row.message.content or "")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_assistant(row: Row, *, reasoning: bool) -> None:
|
||||||
|
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||||
|
|
||||||
|
message = row.message
|
||||||
|
assert isinstance(message, AssistantChatMessage)
|
||||||
|
click.echo(row.prefix())
|
||||||
|
content = message.content
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
if markdown_render_available():
|
||||||
|
print_markdown(content, label="")
|
||||||
|
else:
|
||||||
|
click.echo(content)
|
||||||
|
if reasoning:
|
||||||
|
shown = message.reasoning_content
|
||||||
|
if isinstance(shown, str) and shown.strip():
|
||||||
|
click.secho("reasoning:", fg="bright_black")
|
||||||
|
click.echo(shown)
|
||||||
|
names = _tool_call_names(message)
|
||||||
|
if names:
|
||||||
|
click.secho(f" tool_calls=[{', '.join(names)}]", fg="yellow")
|
||||||
|
if not names and not _content(message).strip():
|
||||||
|
click.echo("(empty)")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
def echo_turn(turn: Turn, *, verbose: bool, detail: Detail) -> None:
|
||||||
|
"""Print one turn: its header, then every row (verbose) or just its ends."""
|
||||||
|
click.secho(turn.label(), fg="yellow" if turn.incomplete else "bright_black")
|
||||||
|
if verbose:
|
||||||
|
rows = turn.rows
|
||||||
|
elif turn.last is turn.user:
|
||||||
|
rows = (turn.user,)
|
||||||
|
else:
|
||||||
|
rows = (turn.user, turn.last)
|
||||||
|
for row in rows:
|
||||||
|
echo_row(row, detail=_row_detail(row, turn=turn, detail=detail))
|
||||||
|
|
||||||
|
|
||||||
|
def _row_detail(row: Row, *, turn: Turn, detail: Detail) -> Detail:
|
||||||
|
"""Effective detail for *row*: mixed prints the turn's answer in full."""
|
||||||
|
if detail != "mixed":
|
||||||
|
return detail
|
||||||
|
return "full" if row is turn.last and isinstance(row.message, AssistantChatMessage) else "preview"
|
||||||
|
|
||||||
|
|
||||||
|
def echo_prelude_rows(rows: Sequence[Row], *, detail: Detail) -> None:
|
||||||
|
"""Print rows that precede the first turn (memory seed, restart notice, …).
|
||||||
|
|
||||||
|
Local-only rows (the injected system prompt) are control-plane noise, so they
|
||||||
|
only appear in a full dump (``-vv``) — numbered head rows always do.
|
||||||
|
"""
|
||||||
|
for row in rows:
|
||||||
|
if row.number is None and detail != "full":
|
||||||
|
continue
|
||||||
|
echo_row(row, detail="preview" if detail == "mixed" else detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_detail(*, verbose: int, preview: bool) -> Detail:
|
||||||
|
"""Map ``-v`` / ``-vv`` / ``--preview`` onto the detail policy."""
|
||||||
|
if verbose > MAX_VERBOSE:
|
||||||
|
msg = "use at most -vv"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
if verbose == MAX_VERBOSE and preview:
|
||||||
|
msg = "use only one of -vv / --preview"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
if verbose == MAX_VERBOSE:
|
||||||
|
return "full"
|
||||||
|
return "preview" if preview else "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_message(view: Transcript, head: str, number: int, *, detail: Detail) -> None:
|
||||||
|
"""Print one addressed message in full (``--message``)."""
|
||||||
|
row = view.row(number)
|
||||||
|
if row is None:
|
||||||
|
click.echo(f"error: no message {number} (messages 1-{view.messages})")
|
||||||
|
return
|
||||||
|
body: Detail = "preview" if detail == "preview" else "full"
|
||||||
|
click.echo(f"{head} showing=message {number} mode={body}")
|
||||||
|
echo_row(row, detail=body)
|
||||||
|
owner = view.turn_of(number)
|
||||||
|
if owner is not None:
|
||||||
|
click.secho(f"(turn {owner.ordinal}; /history {owner.ordinal} -v to see it whole)", fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
|
def _select_turns(
|
||||||
|
view: Transcript,
|
||||||
|
*,
|
||||||
|
target: TurnTarget | None,
|
||||||
|
count: int | None,
|
||||||
|
) -> tuple[tuple[Turn, ...], str] | None:
|
||||||
|
"""Resolve ``/history [N]`` / ``/history last [N]`` into turns to print."""
|
||||||
|
if target is None or target == "last":
|
||||||
|
want = count if count is not None else 1
|
||||||
|
selected = view.turns[-want:]
|
||||||
|
return selected, f"last {len(selected)} turn(s)"
|
||||||
|
turn = view.turn(target)
|
||||||
|
if turn is None:
|
||||||
|
click.echo(f"error: no turn {target} (turns 1-{len(view.turns)})")
|
||||||
|
return None
|
||||||
|
return (turn,), f"turn {target}"
|
||||||
|
|
||||||
|
|
||||||
|
def echo_history(
|
||||||
|
messages: Sequence[AnyChatMessage],
|
||||||
|
*,
|
||||||
|
session_id: int | None,
|
||||||
|
pending_retry_text: str | None = None,
|
||||||
|
target: TurnTarget | None = None,
|
||||||
|
count: int | None = None,
|
||||||
|
message_no: int | None = None,
|
||||||
|
verbose: int = 0,
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Render ``/history``: a turn window, or one addressed message.
|
||||||
|
|
||||||
|
``target`` is a turn number or ``"last"``; ``count`` (only with ``"last"``)
|
||||||
|
is how many turns; ``message_no`` prints a single row instead. Numbering is
|
||||||
|
the display ordinal of this transcript, never a database row number.
|
||||||
|
"""
|
||||||
|
if message_no is not None and (target is not None or count is not None):
|
||||||
|
msg = "use --message N alone"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
if message_no is not None and verbose:
|
||||||
|
msg = "-v/-vv apply to turns: /history N"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
if count is not None and target != "last":
|
||||||
|
msg = "a count applies to 'last': /history last [N]"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
detail = _resolve_detail(verbose=verbose, preview=preview)
|
||||||
|
if not messages:
|
||||||
|
click.echo("(no messages in this session)")
|
||||||
|
return
|
||||||
|
view = build_transcript(messages, pending_retry_text=pending_retry_text)
|
||||||
|
head = f"session={session_id} turns={len(view.turns)} messages={view.messages}"
|
||||||
|
|
||||||
|
if message_no is not None:
|
||||||
|
_echo_message(view, head, message_no, detail=detail)
|
||||||
|
return
|
||||||
|
selection = _select_turns(view, target=target, count=count)
|
||||||
|
if selection is None:
|
||||||
|
return
|
||||||
|
selected, showing = selection
|
||||||
|
click.echo(f"{head} showing={showing} mode={detail}")
|
||||||
|
if verbose >= 1 and view.prelude:
|
||||||
|
echo_prelude_rows(view.prelude, detail=detail)
|
||||||
|
if not selected:
|
||||||
|
click.echo("(no turns yet in this session)")
|
||||||
|
for turn in selected:
|
||||||
|
echo_turn(turn, verbose=verbose >= 1, detail=detail)
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"""``/history``: turn grouping, display numbering, verbose levels, addressing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from plyngent.agent import ChatAgent
|
||||||
|
from plyngent.cli.slash import complete_slash_args, handle_slash
|
||||||
|
from plyngent.cli.state import ReplState
|
||||||
|
from plyngent.cli.transcript import build_transcript, one_line
|
||||||
|
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||||
|
from plyngent.config.store import ConfigStore
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionTool,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
from plyngent.memory import MemoryStore
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
|
||||||
|
class StubClient:
|
||||||
|
"""No model calls happen while rendering history."""
|
||||||
|
|
||||||
|
async def chat_completions(self, param: object, *, stream: bool = False) -> object:
|
||||||
|
del param, stream
|
||||||
|
raise AssertionError("history must not call the model")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
st = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=tmp_path,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=False,
|
||||||
|
)
|
||||||
|
st.client = StubClient()
|
||||||
|
st.agent = ChatAgent(st.client, model=st.model, memory=st.memory, session_id=None)
|
||||||
|
await st.new_session("t")
|
||||||
|
yield st
|
||||||
|
await memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call(message_id: str, name: str, args: str = "{}") -> AssistantChatMessage:
|
||||||
|
call = AssistantFunctionToolCall(id=message_id, function=AssistantFunctionTool(name=name, arguments=args))
|
||||||
|
return AssistantChatMessage(content="", tool_calls=[call])
|
||||||
|
|
||||||
|
|
||||||
|
def _two_turns(*, system: bool = False) -> list[AnyChatMessage]:
|
||||||
|
"""Two settled turns; turn 1 has two tool rounds, turn 2 one."""
|
||||||
|
messages: list[AnyChatMessage] = []
|
||||||
|
if system:
|
||||||
|
messages.append(SystemChatMessage(content="You are plyngent. " + "rules. " * 30))
|
||||||
|
messages += [
|
||||||
|
UserChatMessage(content="read src/a.py and fix the typo"),
|
||||||
|
_tool_call("c1", "read_file", '{"path": "src/a.py"}'),
|
||||||
|
ToolChatMessage(tool_call_id="c1", content="L1-30\n" + "print('hi')\n" * 25 + "print('bye')\n"),
|
||||||
|
DeveloperChatMessage(content="[DIRECTIVE CHECKPOINT band=1 tokens≈100000 source=estimate]\nplaybook…"),
|
||||||
|
_tool_call("c2", "edit_replace", '{"path": "src/a.py"}'),
|
||||||
|
ToolChatMessage(tool_call_id="c2", content="replaced 1 occurrence"),
|
||||||
|
AssistantChatMessage(content="Fixed the typo in `src/a.py`.\n\n- line 12 changed"),
|
||||||
|
UserChatMessage(content="now run the tests"),
|
||||||
|
_tool_call("c3", "run_argv", '{"argv": ["pytest", "-q"]}'),
|
||||||
|
ToolChatMessage(tool_call_id="c3", content="12 passed in 3.2s"),
|
||||||
|
AssistantChatMessage(content="Tests pass: **12 passed** in 3.2s."),
|
||||||
|
]
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_shows_the_last_turns_two_ends(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "turns=2 messages=11 showing=last 1 turn(s) mode=mixed" in out
|
||||||
|
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||||
|
assert "8. user: now run the tests" in out
|
||||||
|
# The answer is printed in full; the round's tool rows are not.
|
||||||
|
assert "11. assistant:" in out
|
||||||
|
assert "Tests pass: **12 passed** in 3.2s." in out
|
||||||
|
assert "tool(c3)" not in out
|
||||||
|
assert "turn 1" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verbose_expands_every_row_of_the_turn(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history -v 1") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||||
|
assert "2. assistant: tool_calls=[read_file]" in out
|
||||||
|
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||||
|
assert "4. developer: [DIRECTIVE CHECKPOINT band=1" in out
|
||||||
|
assert "6. tool(c2): replaced 1 occurrence" in out
|
||||||
|
# The turn's final answer stays full even though the row detail is mixed.
|
||||||
|
assert "7. assistant:" in out
|
||||||
|
assert "- line 12 changed" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_double_verbose_prints_full_bodies(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns(system=True)
|
||||||
|
assert await handle_slash(state, "/history -vv 1") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=full" in out
|
||||||
|
# A tool result past the 200-char preview window is printed in full.
|
||||||
|
assert "3. tool(c1):" in out
|
||||||
|
assert "print('bye')" in out
|
||||||
|
# -vv is also what surfaces the local-only system prompt.
|
||||||
|
assert "local. system:" in out
|
||||||
|
assert "You are plyngent." in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verbose_keeps_local_rows_out_of_the_way(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns(system=True)
|
||||||
|
assert await handle_slash(state, "/history -v 1") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "local. system:" not in out
|
||||||
|
# Numbering is over conversation rows only, so the user row is #1.
|
||||||
|
assert "1. user: read src/a.py and fix the typo" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_collapses_every_row(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history last 2 --preview") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "showing=last 2 turn(s) mode=preview" in out
|
||||||
|
assert "7. assistant: Fixed the typo in `src/a.py`." in out
|
||||||
|
assert "11. assistant: Tests pass: **12 passed** in 3.2s." in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_last_n_turns(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history last 2") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||||
|
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turn_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history 9") is True
|
||||||
|
assert "error: no turn 9 (turns 1-2)" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_number_prints_one_row_in_full(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history --message 3") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "showing=message 3 mode=full" in out
|
||||||
|
assert "3. tool(c1):" in out
|
||||||
|
assert "print('bye')" in out
|
||||||
|
assert "(turn 1; /history 1 -v to see it whole)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_number_preview(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history --message 3 --preview") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "showing=message 3 mode=preview" in out
|
||||||
|
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, "/history --message 99") is True
|
||||||
|
assert "error: no message 99 (messages 1-11)" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("command", "message"),
|
||||||
|
[
|
||||||
|
("/history 1 2", "a count applies to 'last'"),
|
||||||
|
("/history --message 3 -v", "-v/-vv apply to turns"),
|
||||||
|
("/history -vv --preview", "use only one of -vv / --preview"),
|
||||||
|
("/history -vvv", "use at most -vv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_usage_errors(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
command: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert await handle_slash(state, command) is True # the REPL keeps running
|
||||||
|
assert message in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
async def test_incomplete_turn_is_marked(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""A retryable turn is flagged; no duplicate ``(pending retry)`` line."""
|
||||||
|
state.agent.messages = [
|
||||||
|
*_two_turns(),
|
||||||
|
UserChatMessage(content="also update the changelog"),
|
||||||
|
_tool_call("c4", "read_file", '{"path": "CHANGELOG.md"}'),
|
||||||
|
ToolChatMessage(tool_call_id="c4", content="L1-4\n# Changelog"),
|
||||||
|
]
|
||||||
|
assert state.agent.pending_retry_text == "also update the changelog"
|
||||||
|
assert await handle_slash(state, "/history") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "turn 3 (msgs 12-14, rounds=1) [incomplete — /retry continues]" in out
|
||||||
|
assert "pending retry" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_turns_yet_shows_the_prelude(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""A compacted session starts with a seed assistant row, no user turn."""
|
||||||
|
state.agent.messages = [
|
||||||
|
SystemChatMessage(content="You are plyngent."),
|
||||||
|
AssistantChatMessage(content="Summary of session 4: …"),
|
||||||
|
]
|
||||||
|
assert await handle_slash(state, "/history") is True
|
||||||
|
assert "(no turns yet in this session)" in capsys.readouterr().out
|
||||||
|
assert await handle_slash(state, "/history -v") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "1. assistant: Summary of session 4: …" in out
|
||||||
|
assert "local system" not in out.replace(".", " ")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state.agent.messages = []
|
||||||
|
assert await handle_slash(state, "/history") is True
|
||||||
|
assert "(no messages in this session)" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_and_row_numbering() -> None:
|
||||||
|
messages = _two_turns()
|
||||||
|
view = build_transcript(messages)
|
||||||
|
assert len(view.turns) == 2
|
||||||
|
assert view.turns[0].ordinal == 1
|
||||||
|
assert view.messages == 11
|
||||||
|
assert view.turn(2) is not None
|
||||||
|
assert view.turn(3) is None
|
||||||
|
row = view.row(11)
|
||||||
|
assert row is not None
|
||||||
|
assert view.turn_of(11) is not None
|
||||||
|
assert view.row(12) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_line_preview() -> None:
|
||||||
|
assert one_line(None) == ""
|
||||||
|
assert one_line("single") == "single"
|
||||||
|
assert one_line("first\nsecond\nthird") == "first (+2 lines)"
|
||||||
|
assert one_line("x" * 250).endswith("…")
|
||||||
|
assert len(one_line("x" * 250)) == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_prefix_is_styled_once() -> None:
|
||||||
|
"""Rows style their own prefix; renderers must not wrap it a second time."""
|
||||||
|
view = build_transcript(_two_turns())
|
||||||
|
row = view.row(1)
|
||||||
|
assert row is not None
|
||||||
|
prefix = row.prefix()
|
||||||
|
assert prefix.count("\x1b[32m") == 1 # green user, exactly one style open
|
||||||
|
assert prefix.startswith("\x1b[32m")
|
||||||
|
assert "1. user:" in prefix
|
||||||
|
local = build_transcript([SystemChatMessage(content="sys prompt")]).prelude[0]
|
||||||
|
assert "local. system:" in local.prefix()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_completion_offers_last_and_verbose(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
state.agent.messages = _two_turns()
|
||||||
|
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||||
|
assert "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||||
|
assert "2" in complete_slash_args(state, "/history", "2")
|
||||||
|
capsys.readouterr()
|
||||||
@@ -186,7 +186,8 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
|||||||
assert "push" in complete_slash_args(state, "/todos", "p")
|
assert "push" in complete_slash_args(state, "/todos", "p")
|
||||||
assert "pop" in complete_slash_args(state, "/todos", "p")
|
assert "pop" in complete_slash_args(state, "/todos", "p")
|
||||||
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
||||||
assert "--full" in complete_slash_args(state, "/history", "--f")
|
assert "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||||
|
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||||
assert "32" in complete_slash_args(state, "/rounds", "3")
|
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||||
|
|
||||||
from plyngent.agent.todo_stack import TodoStack
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.Cap
|
|||||||
assert await handle_slash(state, "/help history") is True
|
assert await handle_slash(state, "/help history") is True
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Usage: /history" in out
|
assert "Usage: /history" in out
|
||||||
# Real flags for full/preview are documented; Click's meta --help is not.
|
# Real flags are documented; Click's meta --help is not.
|
||||||
assert "--full" in out
|
assert "--verbose" in out
|
||||||
assert "--preview" in out
|
assert "--preview" in out
|
||||||
assert " --help" not in out
|
assert " --help" not in out
|
||||||
|
|
||||||
@@ -498,70 +498,6 @@ async def test_resume(state: ReplState) -> None:
|
|||||||
assert await handle_slash(state, f"/resume {sid}") is True
|
assert await handle_slash(state, f"/resume {sid}") is True
|
||||||
|
|
||||||
|
|
||||||
async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
|
||||||
|
|
||||||
state.agent.messages = [
|
|
||||||
UserChatMessage(content="hello"),
|
|
||||||
AssistantChatMessage(content="hi there"),
|
|
||||||
]
|
|
||||||
assert await handle_slash(state, "/history") is True
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "mode=preview" in out
|
|
||||||
assert "user: hello" in out
|
|
||||||
assert "assistant: hi there" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
|
||||||
|
|
||||||
long_body = "# Title\n\n" + ("paragraph text. " * 20)
|
|
||||||
state.agent.messages = [
|
|
||||||
UserChatMessage(content="hello"),
|
|
||||||
AssistantChatMessage(content=long_body),
|
|
||||||
]
|
|
||||||
assert await handle_slash(state, "/history last") is True
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "mode=full" in out
|
|
||||||
assert "assistant:" in out
|
|
||||||
# Full mode should not use the 200-char preview ellipsis on the body alone
|
|
||||||
assert long_body[:50] in out or "Title" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
|
||||||
|
|
||||||
state.agent.messages = [UserChatMessage(content="only me")]
|
|
||||||
assert await handle_slash(state, "/history 1") is True
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "mode=full" in out
|
|
||||||
assert "only me" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
|
||||||
|
|
||||||
state.agent.messages = [UserChatMessage(content="x" * 250)]
|
|
||||||
assert await handle_slash(state, "/history 1 --preview") is True
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "mode=preview" in out
|
|
||||||
assert "…" in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
|
||||||
|
|
||||||
body = "full " * 100
|
|
||||||
state.agent.messages = [
|
|
||||||
UserChatMessage(content="u"),
|
|
||||||
AssistantChatMessage(content=body),
|
|
||||||
]
|
|
||||||
assert await handle_slash(state, "/history 2 --full") is True
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "mode=full" in out
|
|
||||||
assert body[:40] in out
|
|
||||||
|
|
||||||
|
|
||||||
async def test_rounds(state: ReplState) -> None:
|
async def test_rounds(state: ReplState) -> None:
|
||||||
assert await handle_slash(state, "/rounds 40") is True
|
assert await handle_slash(state, "/rounds 40") is True
|
||||||
assert state.max_rounds == 40
|
assert state.max_rounds == 40
|
||||||
|
|||||||
Reference in New Issue
Block a user