fix(session): correct field names and data types in Session.as_cli_dict() for spec compliance
CI / status-check (push) Blocked by required conditions
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / helm (push) Waiting to run
CI / e2e_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / benchmark-regression (push) Blocked by required conditions
CI / benchmark-publish (push) Waiting to run
CI / build (push) Waiting to run

Fixes issue #3440: agents session show JSON output uses wrong field names and wrong data types.

- Add LinkedPlan value object with plan_id, phase, state fields
- Add automation field to Session domain model
- Rewrite as_cli_dict() with spec-compliant field names in session_summary wrapper
- Use 'text' key in recent_messages items
- Replace linked_plan_ids with linked_plans objects
- Format estimated_cost as string (e.g. '$0.0184')
- Update session show rich output with spec-compliant labels
- Update tests to assert new field names

ISSUES CLOSED: #3440

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
This commit was merged in pull request #3461.
This commit is contained in:
2026-04-05 18:19:19 +00:00
committed by Forgejo
parent a804506c89
commit 4d32281351
5 changed files with 216 additions and 41 deletions
+27 -6
View File
@@ -1109,24 +1109,45 @@ Feature: Consolidated Domain Models
Scenario: CLI dict has required fields
Given a session with some messages
When I get the session CLI dict
Then the session cli dict should have key "session_id"
And the session cli dict should have key "message_count"
And the session cli dict should have key "created_at"
And the session cli dict should have key "updated_at"
Then the session cli dict should have key "session_summary"
And the session cli dict should have key "token_usage"
And the session cli dict should have key "namespace"
And the session cli dict session_summary should have key "id"
And the session cli dict session_summary should have key "messages"
And the session cli dict session_summary should have key "created"
And the session cli dict session_summary should have key "updated"
Scenario: CLI dict includes recent messages
Given a session with some messages
When I get the session CLI dict
Then the session cli dict should have key "recent_messages"
And the session cli dict recent_messages text key should be "text"
Scenario: CLI dict includes actor when set
When I create a session with actor name "local/orchestrator"
And I get the session CLI dict
Then the session cli dict should have key "actor_name"
Then the session cli dict session_summary should have key "actor"
Scenario: CLI dict includes automation when set
When I create a session with automation "review"
And I get the session CLI dict
Then the session cli dict session_summary should have key "automation"
And the session cli dict session_summary automation should be "review"
Scenario: CLI dict linked_plans uses spec-compliant objects
Given a session with linked plans
When I get the session CLI dict
Then the session cli dict should have key "linked_plans"
And the session cli dict linked_plans should contain plan_id field
Scenario: CLI dict token_usage estimated_cost is formatted string
Given a session with token usage cost 0.0184
When I get the session CLI dict
Then the session cli dict token_usage estimated_cost should be a string starting with "$"
# ---- Empty Session Properties ----
+97
View File
@@ -8,6 +8,7 @@ from pydantic import ValidationError
from ulid import ULID
from cleveragents.domain.models.core.session import (
LinkedPlan,
MessageRole,
Session,
SessionExportError,
@@ -460,6 +461,102 @@ def session_model_check_cli_dict_key(context: Context, key: str) -> None:
)
@then('the session cli dict session_summary should have key "{key}"')
def session_model_check_cli_dict_session_summary_key(
context: Context, key: str
) -> None:
"""Check the CLI dict session_summary sub-dict has a specific key."""
summary = context.session_cli_dict.get("session_summary", {})
assert key in summary, (
f"Expected key '{key}' in session_summary, keys: {list(summary)}"
)
@then('the session cli dict session_summary automation should be "{expected}"')
def session_model_check_cli_dict_automation(context: Context, expected: str) -> None:
"""Check the CLI dict session_summary automation value."""
summary = context.session_cli_dict.get("session_summary", {})
actual = summary.get("automation")
assert actual == expected, (
f"Expected session_summary.automation '{expected}', got '{actual}'"
)
@then('the session cli dict recent_messages text key should be "text"')
def session_model_check_cli_dict_recent_messages_text_key(context: Context) -> None:
"""Check that recent_messages items use 'text' key (not 'content')."""
recent = context.session_cli_dict.get("recent_messages", [])
assert recent, "Expected recent_messages to be non-empty"
for msg in recent:
assert "text" in msg, (
f"Expected 'text' key in recent_messages item, got keys: {list(msg)}"
)
assert "content" not in msg, (
"Unexpected 'content' key in recent_messages item (should be 'text')"
)
@then("the session cli dict linked_plans should contain plan_id field")
def session_model_check_cli_dict_linked_plans_plan_id(context: Context) -> None:
"""Check that linked_plans items contain plan_id field."""
plans = context.session_cli_dict.get("linked_plans", [])
assert plans, "Expected linked_plans to be non-empty"
for plan in plans:
assert "plan_id" in plan, (
f"Expected 'plan_id' key in linked_plans item, got keys: {list(plan)}"
)
@then(
'the session cli dict token_usage estimated_cost should be a string starting with "$"'
)
def session_model_check_cli_dict_estimated_cost_string(context: Context) -> None:
"""Check that token_usage.estimated_cost is a formatted string."""
token_usage = context.session_cli_dict.get("token_usage", {})
cost = token_usage.get("estimated_cost")
assert isinstance(cost, str), (
f"Expected estimated_cost to be a string, got {type(cost).__name__}: {cost!r}"
)
assert cost.startswith("$"), (
f"Expected estimated_cost to start with '$', got: {cost!r}"
)
@when('I create a session with automation "{automation}"')
def session_model_create_with_automation(context: Context, automation: str) -> None:
"""Create a session with a specific automation profile name."""
context.session_model = _make_session(automation=automation)
context.session_model_error = None
@given("a session with linked plans")
def session_model_given_with_linked_plans(context: Context) -> None:
"""Create a session with linked plans."""
context.session_model = _make_session(
linked_plans=[
LinkedPlan(
plan_id=str(ULID()),
phase="execute",
state="complete",
)
]
)
context.session_model_error = None
@given("a session with token usage cost {cost:g}")
def session_model_given_with_token_cost(context: Context, cost: float) -> None:
"""Create a session with a specific token usage cost."""
context.session_model = _make_session(
token_usage=SessionTokenUsage(
input_tokens=100,
output_tokens=50,
estimated_cost=cost,
)
)
context.session_model_error = None
# ---------------------------------------------------------------------------
# Automation Level Steps
# ---------------------------------------------------------------------------
+22 -19
View File
@@ -373,15 +373,16 @@ def show(
typer.echo(format_output(dict(data), fmt))
return
# Session summary panel
# Session summary panel — spec-compliant field labels
details = (
f"[bold]Session ID:[/bold] {session.session_id}\n"
f"[bold]ID:[/bold] {session.session_id}\n"
f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n"
f"[bold]Namespace:[/bold] {session.namespace}\n"
f"[bold]Messages:[/bold] {session.message_count}\n"
f"[bold]Created:[/bold] {session.created_at.strftime('%Y-%m-%d %H:%M')}\n"
f"[bold]Updated:[/bold] {session.updated_at.strftime('%Y-%m-%d %H:%M')}"
)
if session.automation is not None:
details += f"\n[bold]Automation:[/bold] {session.automation}"
console.print(Panel(details, title="Session Summary", expand=False))
# Recent messages
@@ -389,29 +390,33 @@ def show(
recent = session.messages[-5:]
msg_table = Table(title="Recent Messages", show_header=True)
msg_table.add_column("Role", style="cyan")
msg_table.add_column("Content")
msg_table.add_column("Timestamp", style="dim")
msg_table.add_column("Text")
for msg in recent:
content = msg.content
if len(content) > 80:
content = content[:77] + "..."
msg_table.add_row(
msg.role.value,
content,
msg.timestamp.strftime("%H:%M:%S"),
)
text = msg.content
if len(text) > 80:
text = text[:77] + "..."
msg_table.add_row(msg.role.value, text)
console.print(msg_table)
# Linked plans
if session.linked_plan_ids:
# Linked plans — spec requires Plan ID / Phase / State columns
if session.linked_plans:
plan_table = Table(title="Linked Plans", show_header=True)
plan_table.add_column("Plan ID", style="cyan")
plan_table.add_column("Phase")
plan_table.add_column("State")
for lp in session.linked_plans:
plan_table.add_row(lp.plan_id, lp.phase, lp.state)
console.print(Panel(plan_table, title="Linked Plans", expand=False))
elif session.linked_plan_ids:
# Fallback: only flat IDs available
plan_text = "\n".join(f"{pid}" for pid in session.linked_plan_ids)
console.print(Panel(plan_text, title="Linked Plans", expand=False))
# Token usage
tu = session.token_usage
usage_text = (
f"[blue]Input Tokens:[/blue] {tu.input_tokens}\n"
f"[blue]Output Tokens:[/blue] {tu.output_tokens}\n"
f"[blue]Input Tokens:[/blue] {tu.input_tokens:,}\n"
f"[blue]Output Tokens:[/blue] {tu.output_tokens:,}\n"
f"[yellow]Estimated Cost:[/yellow] ${tu.estimated_cost:.4f}"
)
console.print(Panel(usage_text, title="Token Usage", expand=False))
@@ -440,8 +445,6 @@ def show(
console.print("[green bold]✓ OK[/green bold] Session details loaded")
console.print("[green bold]✓ OK[/green bold] Session details loaded")
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
@@ -277,6 +277,7 @@ from cleveragents.domain.models.core.sandbox_strategy import (
SandboxStrategyProtocol,
)
from cleveragents.domain.models.core.session import (
LinkedPlan,
MessageRole,
Session,
SessionExportError,
@@ -430,6 +431,7 @@ __all__ = [
"InvocationTracker",
"LegacyChangeSet",
"LifecyclePlan",
"LinkedPlan",
"LinkedResource",
"MaxContextCount",
"MessageRole",
+68 -16
View File
@@ -165,6 +165,26 @@ class SessionTokenUsage(BaseModel):
# ---------------------------------------------------------------------------
class LinkedPlan(BaseModel):
"""A plan linked to a session, with phase and state information.
Used in ``Session.as_cli_dict()`` to produce spec-compliant
``linked_plans`` objects containing ``plan_id``, ``phase``, and
``state``.
"""
plan_id: str = Field(..., description="ULID of the linked plan")
phase: str = Field(..., description="Current phase of the plan (e.g. 'execute')")
state: str = Field(
..., description="Current processing state of the plan (e.g. 'complete')"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class Session(BaseModel):
"""Domain model for a Session.
@@ -193,13 +213,21 @@ class Session(BaseModel):
default="local",
description="Namespace for session ownership",
)
automation: str | None = Field(
default=None,
description="Automation profile name associated with this session",
)
messages: list[SessionMessage] = Field(
default_factory=list,
description="Ordered list of messages in this session",
)
linked_plan_ids: list[str] = Field(
default_factory=list,
description="ULID references to linked plans",
description="ULID references to linked plans (flat list for persistence)",
)
linked_plans: list[LinkedPlan] = Field(
default_factory=list,
description="Linked plans with phase and state details",
)
token_usage: SessionTokenUsage = Field(
default_factory=SessionTokenUsage,
@@ -330,33 +358,57 @@ class Session(BaseModel):
def as_cli_dict(self) -> OrderedDict[str, Any]:
"""Return a stable-ordered dictionary for CLI rendering.
Matches the output fields from ``agents session show``:
Matches the output fields from ``agents session show`` per spec:
- ``session_id``, ``actor_name``, ``namespace``, ``message_count``
- ``created_at``, ``updated_at``
- ``recent_messages`` (last 5), ``linked_plan_ids``
- ``token_usage`` (input/output/cost), ``metadata``
- ``session_summary``: ``id``, ``actor``, ``messages``, ``created``,
``updated``, ``automation``
- ``recent_messages`` (last 5): ``role``, ``text``
- ``linked_plans``: list of ``{plan_id, phase, state}`` objects
- ``token_usage``: ``input_tokens``, ``output_tokens``,
``estimated_cost`` (formatted string e.g. ``"$0.0184"``)
"""
result: OrderedDict[str, Any] = OrderedDict()
result["session_id"] = self.session_id
# session_summary block — spec-compliant field names
session_summary: OrderedDict[str, Any] = OrderedDict()
session_summary["id"] = self.session_id
if self.actor_name:
result["actor_name"] = self.actor_name
result["namespace"] = self.namespace
result["message_count"] = self.message_count
result["created_at"] = self.created_at.isoformat()
result["updated_at"] = self.updated_at.isoformat()
session_summary["actor"] = self.actor_name
session_summary["messages"] = self.message_count
session_summary["created"] = self.created_at.isoformat()
session_summary["updated"] = self.updated_at.isoformat()
if self.automation is not None:
session_summary["automation"] = self.automation
result["session_summary"] = session_summary
# recent_messages — use "text" key per spec
if self.messages:
recent = self.messages[-5:]
result["recent_messages"] = [
{"role": m.role.value, "content": m.content} for m in recent
{"role": m.role.value, "text": m.content} for m in recent
]
if self.linked_plan_ids:
result["linked_plan_ids"] = list(self.linked_plan_ids)
# linked_plans — list of {plan_id, phase, state} objects per spec
if self.linked_plans:
result["linked_plans"] = [
{"plan_id": lp.plan_id, "phase": lp.phase, "state": lp.state}
for lp in self.linked_plans
]
elif self.linked_plan_ids:
# Fallback: if only flat IDs are available, emit minimal objects
result["linked_plans"] = [
{"plan_id": pid, "phase": "", "state": ""}
for pid in self.linked_plan_ids
]
# token_usage — estimated_cost as formatted string per spec
cost = self.token_usage.estimated_cost
result["token_usage"] = {
"input_tokens": self.token_usage.input_tokens,
"output_tokens": self.token_usage.output_tokens,
"estimated_cost": self.token_usage.estimated_cost,
"estimated_cost": f"${cost:.4f}",
}
if self.metadata:
result["metadata"] = dict(self.metadata)
if self.cost_budget is not None: