forked from HAL9000/cleveragents-core
fix(cli): fix session tell output to return spec-required structured response (#6452)
ISSUES CLOSED: #6452
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user