diff --git a/features/consolidated_domain_models.feature b/features/consolidated_domain_models.feature index 36bfb0d9e..eb72e226e 100644 --- a/features/consolidated_domain_models.feature +++ b/features/consolidated_domain_models.feature @@ -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 ---- diff --git a/features/steps/session_model_steps.py b/features/steps/session_model_steps.py index 0c479b139..f56a60661 100644 --- a/features/steps/session_model_steps.py +++ b/features/steps/session_model_steps.py @@ -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 # --------------------------------------------------------------------------- diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index c237e781c..b36a661d2 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -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 diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index 6aa1b6f78..632f254d7 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -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", diff --git a/src/cleveragents/domain/models/core/session.py b/src/cleveragents/domain/models/core/session.py index 05c6b97ce..5a52618ab 100644 --- a/src/cleveragents/domain/models/core/session.py +++ b/src/cleveragents/domain/models/core/session.py @@ -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: