diff --git a/features/session_cli.feature b/features/session_cli.feature index 29effefaea..5295b90dbd 100644 --- a/features/session_cli.feature +++ b/features/session_cli.feature @@ -168,18 +168,28 @@ Feature: Session CLI commands And the session CLI output should contain "Invalid JSON" # Tell command tests - Scenario: Tell appends message to session + Scenario: Tell renders structured output panels Given there is a mocked session for tell When I run session CLI tell with a prompt "Hello, world" Then the session CLI tell should succeed - And the session CLI output should contain "Acknowledged" + And the session CLI output should contain "Plan Request" + And the session CLI output should contain "Usage" + And the session CLI output should contain "Stub orchestrator acknowledged request" - Scenario: Tell with custom actor + Scenario: Tell with custom actor displays actor in panel Given there is a mocked session for tell When I run session CLI tell with --actor "openai/gpt-4" and prompt "Plan a feature" Then the session CLI tell should succeed And the session CLI output should contain "openai/gpt-4" + Scenario: Tell with JSON format returns structured envelope + Given there is a mocked session for tell + When I run session CLI tell with --format json and prompt "Review session" + Then the session CLI tell should succeed + And the session CLI output should be valid JSON + And the session CLI JSON should contain "plan_request" + And the session CLI JSON should contain "usage" + Scenario: Tell to non-existent session When I run session CLI tell to a non-existent session Then the session CLI should exit with error diff --git a/features/steps/session_cli_steps.py b/features/steps/session_cli_steps.py index 045fae897c..9e19313993 100644 --- a/features/steps/session_cli_steps.py +++ b/features/steps/session_cli_steps.py @@ -460,6 +460,18 @@ def step_tell_with_actor(context: Context, actor: str, prompt: str) -> None: ) +@when('I run session CLI tell with --format json and prompt "{prompt}"') +def step_tell_with_json_format(context: Context, prompt: str) -> None: + context.mock_service.append_message.side_effect = [ + _make_message(MessageRole.USER, prompt, 0), + _make_message(MessageRole.ASSISTANT, f"Acknowledged: {prompt}", 1), + ] + context.result = context.runner.invoke( + session_app, + ["tell", "--session", context.session_id, "--format", "json", prompt], + ) + + @when("I run session CLI tell to a non-existent session") def step_tell_nonexistent(context: Context) -> None: context.mock_service.append_message.side_effect = SessionNotFoundError( diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 25229f8403..03d6c348c1 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -18,7 +18,9 @@ import json import logging import sys from collections import OrderedDict +import shlex from pathlib import Path +import textwrap from typing import Annotated, Any, cast import typer @@ -162,6 +164,225 @@ def _session_list_dict(sessions: list[Session]) -> dict[str, Any]: } +def _truncate_prompt(prompt: str, max_length: int = 72) -> str: + """Return a prompt trimmed to ``max_length`` characters with ellipsis.""" + text = prompt.strip() + if len(text) <= max_length: + return text + # ``textwrap.shorten`` collapses internal whitespace and adds the placeholder. + return textwrap.shorten(text, width=max_length, placeholder="...") + + +def _build_tell_command( + *, prompt: str, session_id: str, actor: str | None, stream: bool +) -> str: + """Construct the canonical command string for envelope metadata.""" + parts: list[str] = ["agents", "session", "tell"] + if stream: + parts.append("--stream") + parts.extend(["--session", session_id]) + if actor: + parts.extend(["--actor", actor]) + parts.append(shlex.quote(prompt)) + return " ".join(parts) + + +def _build_tell_payload( + *, + prompt: str, + session_id: str, + actor_display: str, + automation: str, + stream: bool, + assistant_content: str | None = None, +) -> dict[str, Any]: + """Assemble the structured payload for ``session tell`` output.""" + plan_request: dict[str, Any] = { + "actor": actor_display, + "session": session_id, + "prompt": _truncate_prompt(prompt, max_length=96), + } + if stream: + plan_request["mode"] = "stream" + else: + plan_request["automation"] = automation + + result_block: dict[str, Any] = { + "action": None, + "project": None, + "resource": None, + "note": "Stub orchestrator has not executed commands yet.", + } + + usage_block: dict[str, Any] = { + "input_tokens": 0, + "output_tokens": 0, + "cost": 0.0, + "duration_s": 0.0, + "tool_calls": 0, + } + + payload: dict[str, Any] = { + "plan_request": plan_request, + "commands_executed": [], + "result": result_block, + "usage": usage_block, + } + if assistant_content is not None: + payload["assistant_response"] = assistant_content + return payload + + +def _format_tell_plain( + payload: dict[str, Any], + status_text: str, + *, + stream: bool = False, + assistant_content: str | None = None, +) -> str: + """Render the tell payload in plain-text format.""" + + lines: list[str] = [] + plan = payload["plan_request"] + title = "Session" if stream else "Plan Request" + lines.append(title) + lines.append(f" Actor: {plan['actor']}") + lines.append(f" Session: {plan['session']}") + if stream: + lines.append(" Mode: stream") + else: + lines.append(f" Automation: {plan['automation']}") + lines.append(f" Prompt: {plan['prompt']}") + lines.append("") + + if not stream: + lines.append("Commands Executed") + commands = payload["commands_executed"] + if commands: + for command in commands: + lines.append(f" - {command}") + else: + lines.append(" (none)") + lines.append("") + + lines.append("Result") + result = payload.get("result") or {} + included_any = False + for key, label in ( + ("action", "Action"), + ("project", "Project"), + ("resource", "Resource"), + ): + value = result.get(key) + if value: + lines.append(f" {label}: {value}") + included_any = True + note = result.get("note") + if note: + lines.append(f" Note: {note}") + included_any = True + if not included_any: + lines.append(" (pending)") + lines.append("") + elif assistant_content: + lines.append("Assistant Response") + lines.append(f" {assistant_content}") + lines.append("") + + usage = payload["usage"] + lines.append("Usage") + lines.append(f" Input Tokens: {usage['input_tokens']}") + lines.append(f" Output Tokens: {usage['output_tokens']}") + lines.append(f" Cost: ${usage['cost']:.4f}") + lines.append(f" Duration: {usage['duration_s']:.1f}s") + lines.append(f" Tool Calls: {usage['tool_calls']}") + lines.append("") + lines.append(status_text) + + return "\n".join(lines).rstrip() + + +def _render_tell_rich(payload: dict[str, Any], status_text: str) -> None: + """Render the tell payload using Rich panels.""" + + plan = payload["plan_request"] + plan_lines = [ + f"[magenta]Actor:[/magenta] {plan['actor']}", + f"[cyan]Session:[/cyan] {plan['session']}", + ] + if "automation" in plan: + plan_lines.append(f"[yellow]Automation:[/yellow] {plan['automation']}") + plan_lines.append(f"[blue]Prompt:[/blue] {plan['prompt']}") + console.print(Panel("\n".join(plan_lines), title="Plan Request", expand=False)) + + commands = payload["commands_executed"] + if commands: + commands_text = "\n".join(f"- {command}" for command in commands) + else: + commands_text = "(none)" + console.print(Panel(commands_text, title="Commands Executed", expand=False)) + + result = payload.get("result") or {} + result_lines: list[str] = [] + for key, label in ( + ("action", "Action"), + ("project", "Project"), + ("resource", "Resource"), + ): + value = result.get(key) + if value: + result_lines.append(f"[green]{label}:[/green] {value}") + note = result.get("note") + if note: + result_lines.append(note) + if not result_lines: + result_lines.append("(pending orchestrator integration)") + console.print(Panel("\n".join(result_lines), title="Result", expand=False)) + + usage = payload["usage"] + usage_lines = [ + f"[blue]Input Tokens:[/blue] {usage['input_tokens']}", + f"[blue]Output Tokens:[/blue] {usage['output_tokens']}", + f"[yellow]Cost:[/yellow] ${usage['cost']:.4f}", + f"[yellow]Duration:[/yellow] {usage['duration_s']:.1f}s", + f"[yellow]Tool Calls:[/yellow] {usage['tool_calls']}", + ] + console.print(Panel("\n".join(usage_lines), title="Usage", expand=False)) + console.print(f"[green]{status_text}[/green]") + + +def _render_tell_streaming( + payload: dict[str, Any], assistant_content: str, status_text: str +) -> None: + """Render streaming output matching the spec stub behaviour.""" + + plan = payload["plan_request"] + plan_lines = [ + f"[magenta]Actor:[/magenta] {plan['actor']}", + f"[cyan]Session:[/cyan] {plan['session']}", + "[yellow]Mode:[/yellow] stream", + f"[blue]Prompt:[/blue] {plan['prompt']}", + ] + console.print(Panel("\n".join(plan_lines), title="Session", expand=False)) + + for char in assistant_content: + sys.stdout.write(char) + sys.stdout.flush() + sys.stdout.write("\n") + sys.stdout.flush() + + usage = payload["usage"] + usage_lines = [ + f"[blue]Input Tokens:[/blue] {usage['input_tokens']}", + f"[blue]Output Tokens:[/blue] {usage['output_tokens']}", + f"[yellow]Cost:[/yellow] ${usage['cost']:.4f}", + f"[yellow]Duration:[/yellow] {usage['duration_s']:.1f}s", + f"[yellow]Tool Calls:[/yellow] {usage['tool_calls']}", + ] + console.print(Panel("\n".join(usage_lines), title="Usage", expand=False)) + console.print(f"[green]{status_text}[/green]") + + # --------------------------------------------------------------------------- # CLI Commands # --------------------------------------------------------------------------- @@ -807,6 +1028,10 @@ def tell( bool, typer.Option("--stream", help="Stream response in real-time"), ] = False, + fmt: Annotated[ + str, + typer.Option("--format", help=_FORMAT_HELP), + ] = "rich", ) -> None: """Send a message to a session. @@ -820,15 +1045,14 @@ def tell( """ try: service = _get_session_service() + session_obj = service.get(session_id) - # Append user message service.append_message( session_id=session_id, role=MessageRole.USER, content=prompt, ) - # Stub actor execution: generate simple assistant response assistant_content = ( f"Acknowledged: {prompt[:100]}" if not actor @@ -840,17 +1064,67 @@ def tell( content=assistant_content, ) - if stream: - # Simulate streaming by printing character by character - for char in assistant_content: - sys.stdout.write(char) - sys.stdout.flush() - sys.stdout.write("\n") - else: - from rich.markup import escape + actor_display = actor or session_obj.actor_name or "local/orchestrator" + automation = session_obj.automation or "default" + payload = _build_tell_payload( + prompt=prompt, + session_id=session_id, + actor_display=actor_display, + automation=automation, + stream=stream, + assistant_content=assistant_content if stream else None, + ) - console.print(f"[dim]user:[/dim] {escape(prompt)}") - console.print(f"[cyan]assistant:[/cyan] {escape(assistant_content)}") + status_text = "✓ OK Stub orchestrator acknowledged request" + command_str = _build_tell_command( + prompt=prompt, + session_id=session_id, + actor=actor or session_obj.actor_name, + stream=stream, + ) + envelope_messages = [ + { + "level": "ok", + "text": "Stub orchestrator acknowledged request; no commands were executed.", + } + ] + + fmt_lower = fmt.lower() + if fmt_lower in (OutputFormat.JSON.value, OutputFormat.YAML.value): + typer.echo( + format_output( + payload, + fmt_lower, + command=command_str, + status="ok", + exit_code=0, + messages=envelope_messages, + ) + ) + return + + if fmt_lower in (OutputFormat.PLAIN.value, OutputFormat.TABLE.value): + typer.echo( + _format_tell_plain( + payload, + status_text, + stream=stream, + assistant_content=assistant_content if stream else None, + ) + ) + return + + if fmt_lower == OutputFormat.COLOR.value: + if stream: + _render_tell_streaming(payload, assistant_content, status_text) + else: + _render_tell_rich(payload, status_text) + return + + if stream: + _render_tell_streaming(payload, assistant_content, status_text) + else: + _render_tell_rich(payload, status_text) except SessionNotFoundError as exc: console.print(f"[red]Session not found:[/red] {session_id}")