fix(cli/session): add --format flag and JSON envelope output to session tell #10880
@@ -3,8 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
vars:
|
||||
docker_prefix: "http://harbor.cleverthis.com/docker/"
|
||||
|
||||
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`session tell` missing `--format` flag and JSON envelope output** (#10466): Added
|
||||
`--format`/`-f` option to `agents session tell` so machine-readable output (JSON,
|
||||
YAML, plain) is available alongside the existing Rich console output. When a
|
||||
non-rich format is requested the response is wrapped in the spec-required JSON
|
||||
envelope containing session metadata, the assistant response, and usage statistics.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Diagnostics spec examples expanded to all 9 providers** (#5320): Updated the
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Step definitions for TDD session tell --format flag tests.
|
||||
|
||||
These tests verify that ``session tell`` supports the ``--format`` flag
|
||||
and emits a spec-compliant JSON envelope when ``--format json`` is used.
|
||||
|
||||
Issue #10466 (Bug): session tell missing --format flag and JSON envelope output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands import session as session_mod
|
||||
from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.domain.models.core.session import SessionService
|
||||
|
||||
_runner = CliRunner()
|
||||
_SESSION_ID = "01HXYZ1234567890ABCDEFGHIJ"
|
||||
|
||||
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
|
||||
|
||||
|
||||
def _make_mock_service() -> MagicMock:
|
||||
"""Create a MagicMock that passes isinstance checks for SessionService."""
|
||||
svc = MagicMock(spec=SessionService)
|
||||
svc.append_message.return_value = None
|
||||
return svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session tell format mock service")
|
||||
def step_setup_format_mock_service(context: Context) -> None:
|
||||
"""Set up a mock session service for tell format tests."""
|
||||
svc = _make_mock_service()
|
||||
session_mod._service = svc
|
||||
context.mock_svc = svc
|
||||
context.session_id = _SESSION_ID
|
||||
|
||||
def cleanup() -> None:
|
||||
session_mod._service = None
|
||||
|
||||
context.add_cleanup(cleanup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke session tell with --format json and prompt "{prompt}"')
|
||||
def step_invoke_tell_format_json(context: Context, prompt: str) -> None:
|
||||
"""Invoke tell with --format json."""
|
||||
context.result = _runner.invoke(
|
||||
session_app,
|
||||
["tell", "--session", context.session_id, "--format", "json", prompt],
|
||||
)
|
||||
|
||||
|
||||
@when("I invoke session tell with --help")
|
||||
def step_invoke_tell_help(context: Context) -> None:
|
||||
"""Invoke tell --help."""
|
||||
context.result = _runner.invoke(session_app, ["tell", "--help"])
|
||||
|
||||
|
||||
@when('I invoke session tell without --format and prompt "{prompt}"')
|
||||
def step_invoke_tell_no_format(context: Context, prompt: str) -> None:
|
||||
"""Invoke tell without --format (default rich output)."""
|
||||
context.result = _runner.invoke(
|
||||
session_app,
|
||||
["tell", "--session", context.session_id, prompt],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke session tell with --format json and --stream and prompt "{prompt}"')
|
||||
def step_invoke_tell_format_json_stream(context: Context, prompt: str) -> None:
|
||||
"""Invoke tell with --format json and --stream."""
|
||||
context.result = _runner.invoke(
|
||||
session_app,
|
||||
[
|
||||
"tell",
|
||||
"--session",
|
||||
context.session_id,
|
||||
"--format",
|
||||
"json",
|
||||
"--stream",
|
||||
prompt,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the tell format command exits with code 0")
|
||||
def step_tell_format_exit_0(context: Context) -> None:
|
||||
"""Assert the command exited with code 0."""
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit code 0, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tell format output is valid JSON")
|
||||
def step_tell_format_valid_json(context: Context) -> None:
|
||||
"""Assert the output is valid JSON."""
|
||||
try:
|
||||
context.parsed_json = json.loads(context.result.output)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(
|
||||
f"Output is not valid JSON:\n{context.result.output}"
|
||||
) from exc
|
||||
|
||||
|
||||
@then('the tell format JSON envelope has key "{key}"')
|
||||
def step_tell_format_envelope_has_key(context: Context, key: str) -> None:
|
||||
"""Assert the JSON envelope has the given top-level key."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
assert key in parsed, (
|
||||
f"Expected key '{key}' in JSON envelope, got keys: {list(parsed.keys())}\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tell format JSON data has key "{key}"')
|
||||
def step_tell_format_data_has_key(context: Context, key: str) -> None:
|
||||
"""Assert the JSON envelope data field has the given key."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
data = parsed.get("data", {})
|
||||
assert key in data, (
|
||||
f"Expected key '{key}' in JSON data, got keys: {list(data.keys())}\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tell format JSON envelope status is "{expected_status}"')
|
||||
def step_tell_format_envelope_status(context: Context, expected_status: str) -> None:
|
||||
"""Assert the JSON envelope status field matches expected value."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
actual_status = parsed.get("status")
|
||||
assert actual_status == expected_status, (
|
||||
f"Expected status '{expected_status}', got '{actual_status}'\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tell format JSON envelope exit_code is {expected_code:d}")
|
||||
def step_tell_format_envelope_exit_code(context: Context, expected_code: int) -> None:
|
||||
"""Assert the JSON envelope exit_code field matches expected value."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
actual_code = parsed.get("exit_code")
|
||||
assert actual_code == expected_code, (
|
||||
f"Expected exit_code {expected_code}, got {actual_code}\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tell format output contains "{text}"')
|
||||
def step_tell_format_output_contains(context: Context, text: str) -> None:
|
||||
"""Assert the output contains the given text."""
|
||||
assert text in context.result.output, (
|
||||
f"Expected '{text}' in output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the tell format output does not contain "{text}"')
|
||||
def step_tell_format_output_not_contains(context: Context, text: str) -> None:
|
||||
"""Assert the output does not contain the given text."""
|
||||
assert text not in context.result.output, (
|
||||
f"Expected '{text}' NOT in output, but found it:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tell format output does not contain '{text}'")
|
||||
def step_tell_format_output_not_contains_sq(context: Context, text: str) -> None:
|
||||
"""Assert the output does not contain the given text (single-quoted step form)."""
|
||||
assert text not in context.result.output, (
|
||||
f"Expected '{text}' NOT in output, but found it:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tell format JSON data session has an id field")
|
||||
def step_tell_format_data_session_id(context: Context) -> None:
|
||||
"""Assert the JSON data.session has an id field."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
data = parsed.get("data", {})
|
||||
session = data.get("session", {})
|
||||
assert "id" in session, (
|
||||
f"Expected 'id' in data.session, got keys: {list(session.keys())}\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
assert session["id"] == _SESSION_ID, (
|
||||
f"Expected session id '{_SESSION_ID}', got '{session['id']}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the tell format JSON data session mode is "{expected_mode}"')
|
||||
def step_tell_format_data_session_mode(context: Context, expected_mode: str) -> None:
|
||||
"""Assert the JSON data.session.mode matches expected value."""
|
||||
parsed = getattr(context, "parsed_json", None)
|
||||
if parsed is None:
|
||||
parsed = json.loads(context.result.output)
|
||||
context.parsed_json = parsed
|
||||
data = parsed.get("data", {})
|
||||
session = data.get("session", {})
|
||||
actual_mode = session.get("mode")
|
||||
assert actual_mode == expected_mode, (
|
||||
f"Expected session mode '{expected_mode}', got '{actual_mode}'\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Feature: TDD — session tell --format flag and JSON envelope output
|
||||
As a developer using the CLI
|
||||
I want `agents session tell` to support the `--format` flag
|
||||
So that I can get machine-readable JSON envelope output from the tell command
|
||||
|
||||
Background:
|
||||
Given a session tell format mock service
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell --format json emits a spec-compliant JSON envelope
|
||||
When I invoke session tell with --format json and prompt "Hello world"
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output is valid JSON
|
||||
And the tell format JSON envelope has key "command"
|
||||
And the tell format JSON envelope has key "status"
|
||||
And the tell format JSON envelope has key "exit_code"
|
||||
And the tell format JSON envelope has key "data"
|
||||
And the tell format JSON envelope has key "timing"
|
||||
And the tell format JSON envelope has key "messages"
|
||||
And the tell format JSON data has key "session"
|
||||
And the tell format JSON data has key "response"
|
||||
And the tell format JSON data has key "usage"
|
||||
And the tell format JSON envelope status is "ok"
|
||||
And the tell format JSON envelope exit_code is 0
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell --help shows --format flag
|
||||
When I invoke session tell with --help
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output contains "--format"
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell without --format continues to output Rich console text
|
||||
When I invoke session tell without --format and prompt "Hello world"
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output contains "Acknowledged"
|
||||
And the tell format output does not contain '"status"'
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell --format json includes session id in data
|
||||
When I invoke session tell with --format json and prompt "Test message"
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output is valid JSON
|
||||
And the tell format JSON data session has an id field
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell --format json with --stream sets mode to streaming
|
||||
When I invoke session tell with --format json and --stream and prompt "Stream test"
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output is valid JSON
|
||||
And the tell format JSON data session mode is "streaming"
|
||||
|
||||
@tdd_issue @tdd_issue_10466
|
||||
Scenario: session tell --format json without --stream sets mode to standard
|
||||
When I invoke session tell with --format json and prompt "Standard test"
|
||||
Then the tell format command exits with code 0
|
||||
And the tell format output is valid JSON
|
||||
And the tell format JSON data session mode is "standard"
|
||||
@@ -806,6 +806,10 @@ def tell(
|
||||
bool,
|
||||
typer.Option("--stream", help="Stream response in real-time"),
|
||||
] = False,
|
||||
format: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Send a message to a session.
|
||||
|
||||
@@ -816,6 +820,7 @@ def tell(
|
||||
agents session tell --session 01HXYZ... "Hello, world"
|
||||
agents session tell --session 01HXYZ... --actor openai/gpt-4 "Plan a feature"
|
||||
agents session tell --session 01HXYZ... --stream "Build tests"
|
||||
agents session tell --session 01HXYZ... --format json "Hello"
|
||||
"""
|
||||
try:
|
||||
service = _get_session_service()
|
||||
@@ -839,6 +844,24 @@ def tell(
|
||||
content=assistant_content,
|
||||
)
|
||||
|
||||
if format not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
|
||||
mode = "streaming" if stream else "standard"
|
||||
data: dict[str, Any] = {
|
||||
"session": {
|
||||
"id": session_id,
|
||||
"actor": actor,
|
||||
"mode": mode,
|
||||
},
|
||||
"response": assistant_content,
|
||||
"usage": {
|
||||
"tokens": 0,
|
||||
"duration_s": 0.0,
|
||||
"tool_calls": 0,
|
||||
},
|
||||
}
|
||||
typer.echo(format_output(data, format, command="agents session tell"))
|
||||
return
|
||||
|
||||
if stream:
|
||||
# Route streaming output through the Rich console so that the
|
||||
# redaction layer is applied before any content reaches stdout.
|
||||
|
||||
Reference in New Issue
Block a user