fix(cli): fix session tell output to return spec-required structured response (#6452)

ISSUES CLOSED: #6452
This commit is contained in:
2026-04-09 23:47:38 +00:00
parent 8a87675a58
commit dc435aa66e
3 changed files with 311 additions and 15 deletions
+13 -3
View File
@@ -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
+12
View File
@@ -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(
+286 -12
View File
@@ -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}")