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
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:
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user