8d62a51561
CI / lint (pull_request) Successful in 1m2s
CI / typecheck (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 1m17s
CI / quality (pull_request) Successful in 51s
CI / build (pull_request) Successful in 58s
CI / push-validation (pull_request) Successful in 52s
CI / helm (pull_request) Successful in 1m27s
CI / unit_tests (pull_request) Successful in 6m50s
CI / docker (pull_request) Successful in 2m0s
CI / integration_tests (pull_request) Successful in 11m14s
CI / coverage (pull_request) Successful in 12m35s
CI / status-check (pull_request) Successful in 3s
The `_format_table` helper documented itself as rendering an "ASCII table" but built a Rich `Table` with the default `HEAVY_HEAD` box, emitting Unicode box-drawing chars (│ ─ ┌). The new @format_flag scenario "Tell table format outputs ASCII table" asserts the output contains `|` or `+`, so the rendered table did not match either the docstring contract or the BDD expectation. Pass `box=box.ASCII` so the table actually uses `|` and `+` borders. Also apply `ruff format` to the new step definitions (four split-string concatenations the formatter wants collapsed onto one line each).
956 lines
34 KiB
Python
956 lines
34 KiB
Python
"""Step definitions for the Session CLI feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
from ulid import ULID
|
|
|
|
from cleveragents.application.services.session_workflow import TellResult
|
|
from cleveragents.cli.commands.session import app as session_app
|
|
from cleveragents.domain.models.core.session import (
|
|
MessageRole,
|
|
Session,
|
|
SessionImportError,
|
|
SessionMessage,
|
|
SessionNotFoundError,
|
|
SessionTokenUsage,
|
|
)
|
|
|
|
_SESSION_ID = str(ULID())
|
|
_SESSION_ID_2 = str(ULID())
|
|
|
|
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
|
|
|
|
|
|
def _unwrap_envelope(parsed: Any) -> Any:
|
|
"""Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
|
|
if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
|
|
return parsed["data"]
|
|
return parsed
|
|
|
|
|
|
def _make_session(
|
|
*,
|
|
session_id: str | None = None,
|
|
actor_name: str | None = None,
|
|
messages: list[SessionMessage] | None = None,
|
|
) -> Session:
|
|
"""Create a test Session instance."""
|
|
return Session(
|
|
session_id=session_id or str(ULID()),
|
|
actor_name=actor_name,
|
|
namespace="local",
|
|
messages=messages or [],
|
|
token_usage=SessionTokenUsage(
|
|
input_tokens=100,
|
|
output_tokens=50,
|
|
estimated_cost=0.005,
|
|
),
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
|
|
|
|
def _make_message(
|
|
role: MessageRole = MessageRole.USER,
|
|
content: str = "Hello",
|
|
sequence: int = 0,
|
|
) -> SessionMessage:
|
|
"""Create a test SessionMessage."""
|
|
return SessionMessage(
|
|
message_id=str(ULID()),
|
|
role=role,
|
|
content=content,
|
|
sequence=sequence,
|
|
timestamp=datetime.now(),
|
|
)
|
|
|
|
|
|
def _cleanup(context: Context) -> None:
|
|
"""Clean up temporary files."""
|
|
for path in getattr(context, "_cleanup_paths", []):
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a session CLI runner with mocked service")
|
|
def step_session_cli_runner(context: Context) -> None:
|
|
"""Set up CLI runner with a mocked session service."""
|
|
context.runner = CliRunner()
|
|
context.mock_service = MagicMock()
|
|
context._cleanup_paths: list[str] = []
|
|
|
|
# Default: create returns a session
|
|
default_session = _make_session(session_id=_SESSION_ID)
|
|
context.mock_service.create.return_value = default_session
|
|
# get_messages returns empty list by default (used by SessionWorkflow)
|
|
context.mock_service.get_messages.return_value = []
|
|
|
|
# Patch _get_session_service so tests are safe for parallel workers
|
|
# and do not mutate the module-level _service cache (M9).
|
|
svc_patcher = patch(
|
|
"cleveragents.cli.commands.session._get_session_service",
|
|
return_value=context.mock_service,
|
|
)
|
|
svc_patcher.start()
|
|
|
|
# Patch _build_session_workflow to return a controllable mock workflow.
|
|
# This lets tell-specific tests configure exact return values without
|
|
# needing a real LLM or provider registry in the test environment.
|
|
context.mock_workflow = MagicMock()
|
|
_default_tell_result = TellResult(
|
|
session_id=_SESSION_ID,
|
|
user_message="",
|
|
assistant_message="Acknowledged",
|
|
input_tokens=10,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.mock_workflow.tell.return_value = _default_tell_result
|
|
context.mock_workflow.tell_stream.return_value = iter(["Acknowledged"])
|
|
|
|
workflow_patcher = patch(
|
|
"cleveragents.cli.commands.session._build_session_workflow",
|
|
return_value=context.mock_workflow,
|
|
)
|
|
workflow_patcher.start()
|
|
|
|
def cleanup() -> None:
|
|
svc_patcher.stop()
|
|
workflow_patcher.stop()
|
|
_cleanup(context)
|
|
|
|
context.add_cleanup(cleanup)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run session CLI create with no arguments")
|
|
def step_create_no_args(context: Context) -> None:
|
|
context.result = context.runner.invoke(session_app, ["create"])
|
|
|
|
|
|
@when('I run session CLI create with --actor "{actor}"')
|
|
def step_create_with_actor(context: Context, actor: str) -> None:
|
|
session = _make_session(session_id=_SESSION_ID, actor_name=actor)
|
|
context.mock_service.create.return_value = session
|
|
context.result = context.runner.invoke(session_app, ["create", "--actor", actor])
|
|
|
|
|
|
@when("I run session CLI create with --format json")
|
|
def step_create_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(session_app, ["create", "--format", "json"])
|
|
|
|
|
|
@then("the session CLI create should succeed")
|
|
def step_create_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there are no mocked sessions")
|
|
def step_no_sessions(context: Context) -> None:
|
|
context.mock_service.list.return_value = []
|
|
|
|
|
|
@given("there are mocked existing sessions")
|
|
def step_existing_sessions(context: Context) -> None:
|
|
sessions = [
|
|
_make_session(
|
|
session_id=_SESSION_ID,
|
|
actor_name="openai/gpt-4",
|
|
messages=[_make_message(sequence=0)],
|
|
),
|
|
_make_session(session_id=_SESSION_ID_2),
|
|
]
|
|
context.mock_service.list.return_value = sessions
|
|
|
|
|
|
@given("there are mocked existing named sessions")
|
|
def step_existing_named_sessions(context: Context) -> None:
|
|
"""Set up mocked sessions with names for Summary panel name-display test."""
|
|
sessions = [
|
|
_make_session(
|
|
session_id=_SESSION_ID,
|
|
actor_name="openai/gpt-4",
|
|
messages=[_make_message(sequence=0)],
|
|
),
|
|
_make_session(session_id=_SESSION_ID_2),
|
|
]
|
|
sessions[0].name = "weekly-planning"
|
|
sessions[1].name = "refactor-sprint"
|
|
context.mock_service.list.return_value = sessions
|
|
|
|
|
|
@when("I run session CLI list")
|
|
def step_list(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["list"], env={"COLUMNS": "200"}
|
|
)
|
|
|
|
|
|
@when("I run session CLI list with --format json")
|
|
def step_list_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["list", "--format", "json"], env={"COLUMNS": "200"}
|
|
)
|
|
|
|
|
|
@then("the session CLI should show all sessions in a table")
|
|
def step_list_shows_table(context: Context) -> None:
|
|
assert context.result.exit_code == 0
|
|
assert "Sessions" in context.result.output
|
|
|
|
|
|
@then("the session CLI rich table should display full session ULIDs")
|
|
def step_list_rich_table_full_ulids(context: Context) -> None:
|
|
"""Verify the Rich table displays the full 26-character session ULIDs."""
|
|
assert context.result.exit_code == 0
|
|
output = context.result.output
|
|
# Restrict assertion to the table region (before the Summary panel) so
|
|
# that a regression back to 8-char truncation is not masked by the full
|
|
# ULID appearing elsewhere (e.g. in the Summary panel).
|
|
summary_idx = output.find("Summary")
|
|
table_output = output[:summary_idx] if summary_idx != -1 else output
|
|
assert _SESSION_ID in table_output, (
|
|
f"Full ULID {_SESSION_ID} not found in Rich table output:\n"
|
|
f"{context.result.output}"
|
|
)
|
|
assert _SESSION_ID_2 in table_output, (
|
|
f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n"
|
|
f"{context.result.output}"
|
|
)
|
|
# Negative guard: ensure the table contains full 26-character ULIDs,
|
|
# not the old 8-character truncated form. A standalone 8-char prefix
|
|
# (not as part of the full ULID) would indicate the [:8] slice was not
|
|
# removed.
|
|
table_without_full_ids = table_output.replace(_SESSION_ID, "").replace(
|
|
_SESSION_ID_2, ""
|
|
)
|
|
assert _SESSION_ID[:8] not in table_without_full_ids, (
|
|
f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n"
|
|
f"{context.result.output}"
|
|
)
|
|
assert _SESSION_ID_2[:8] not in table_without_full_ids, (
|
|
f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n"
|
|
f"{context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the session CLI summary panel should contain full session ULIDs")
|
|
def step_list_summary_full_ulids(context: Context) -> None:
|
|
"""Verify the Summary panel shows full ULIDs for unnamed sessions."""
|
|
assert context.result.exit_code == 0
|
|
output = context.result.output
|
|
assert "Summary" in output, f"Summary panel not found in output:\n{output}"
|
|
# Extract the Summary panel region to avoid a false pass from the Rich
|
|
# table also containing the same full ULIDs.
|
|
summary_idx = output.find("Summary")
|
|
summary_output = output[summary_idx:]
|
|
# Both Most Recent and Oldest entries should display full ULIDs when
|
|
# sessions are unnamed. Asserting only one ID would allow a regression
|
|
# that re-introduced [:8] on one fallback path while keeping the other
|
|
# intact to pass the test undetected.
|
|
assert _SESSION_ID in summary_output, (
|
|
f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}"
|
|
)
|
|
assert _SESSION_ID_2 in summary_output, (
|
|
f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}"
|
|
)
|
|
|
|
|
|
@then("the session CLI summary panel should show session names")
|
|
def step_list_summary_shows_names(context: Context) -> None:
|
|
"""Verify the Summary panel shows session names (not ULIDs) for named sessions."""
|
|
assert context.result.exit_code == 0
|
|
output = context.result.output
|
|
summary_idx = output.find("Summary")
|
|
assert summary_idx != -1, f"Summary panel not found in output:\n{output}"
|
|
summary_output = output[summary_idx:]
|
|
assert "weekly-planning" in summary_output, (
|
|
f"'weekly-planning' not found in Summary panel:\n{output}"
|
|
)
|
|
assert "refactor-sprint" in summary_output, (
|
|
f"'refactor-sprint' not found in Summary panel:\n{output}"
|
|
)
|
|
# The Summary should NOT show the raw ULID when session names are present
|
|
assert _SESSION_ID not in summary_output, (
|
|
f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}"
|
|
)
|
|
assert _SESSION_ID_2 not in summary_output, (
|
|
f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}"
|
|
)
|
|
|
|
|
|
@when("I capture the first session full ULID from the output")
|
|
def step_capture_first_ulid(context: Context) -> None:
|
|
"""Parse the first session's full ULID from the output and store it
|
|
for subsequent steps (round-trip tell test)."""
|
|
assert context.result.exit_code == 0
|
|
output = context.result.output
|
|
# Restrict the search to the table region (before the Summary panel)
|
|
# so that a ULID appearing in the Summary panel is not accidentally
|
|
# captured as the "first" session ID.
|
|
summary_idx = output.find("Summary")
|
|
search_region = output[:summary_idx] if summary_idx != -1 else output
|
|
match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region)
|
|
assert match is not None, (
|
|
f"No 26-character ULID found in table output:\n{search_region}"
|
|
)
|
|
context.full_session_id = match.group()
|
|
assert len(context.full_session_id) == 26, (
|
|
f"Parsed ULID '{context.full_session_id}' is not 26 characters"
|
|
)
|
|
# Sanity check: the captured ID should match one of the known fixture IDs
|
|
assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), (
|
|
f"Captured ULID '{context.full_session_id}' does not match any fixture ID"
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with the full session ID and prompt "{prompt}"')
|
|
def step_tell_with_stored_ulid(context: Context, prompt: str) -> None:
|
|
"""Run session tell using the full ULID stored from session list."""
|
|
session_id = context.full_session_id
|
|
# Configure mock workflow for this prompt.
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Acknowledged: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", session_id, prompt],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Show
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there is a mocked session with messages")
|
|
def step_session_with_messages(context: Context) -> None:
|
|
messages = [
|
|
_make_message(MessageRole.USER, "Hello", 0),
|
|
_make_message(MessageRole.ASSISTANT, "Hi there", 1),
|
|
]
|
|
session = _make_session(
|
|
session_id=_SESSION_ID,
|
|
actor_name="openai/gpt-4",
|
|
messages=messages,
|
|
)
|
|
context.mock_service.get.return_value = session
|
|
context.session_id = _SESSION_ID
|
|
|
|
|
|
@when("I run session CLI show with a valid session ID")
|
|
def step_show_valid(context: Context) -> None:
|
|
context.result = context.runner.invoke(session_app, ["show", context.session_id])
|
|
|
|
|
|
@when("I run session CLI show with --format json")
|
|
def step_show_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["show", context.session_id, "--format", "json"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI show with an invalid session ID")
|
|
def step_show_invalid(context: Context) -> None:
|
|
context.mock_service.get.side_effect = SessionNotFoundError(
|
|
"Session 'INVALID' not found"
|
|
)
|
|
context.result = context.runner.invoke(session_app, ["show", "INVALID"])
|
|
|
|
|
|
@then("the session CLI show should succeed")
|
|
def step_show_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there is a mocked session to delete")
|
|
def step_session_to_delete(context: Context) -> None:
|
|
session = _make_session(session_id=_SESSION_ID)
|
|
context.mock_service.get.return_value = session
|
|
context.mock_service.delete.return_value = None
|
|
context.session_id = _SESSION_ID
|
|
|
|
|
|
@when("I run session CLI delete with --yes")
|
|
def step_delete_yes(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["delete", context.session_id, "--yes"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI delete with --yes and --format json")
|
|
def step_delete_yes_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["delete", context.session_id, "--yes", "--format", "json"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI delete with --yes and --format color")
|
|
def step_delete_yes_color(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["delete", context.session_id, "--yes", "--format", "color"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI delete with a non-existent ID")
|
|
def step_delete_nonexistent(context: Context) -> None:
|
|
context.mock_service.get.side_effect = SessionNotFoundError("Session not found")
|
|
context.result = context.runner.invoke(
|
|
session_app, ["delete", "NONEXISTENT", "--yes"]
|
|
)
|
|
|
|
|
|
@then("the session CLI delete should succeed")
|
|
def step_delete_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there is a mocked session for export")
|
|
def step_session_for_export(context: Context) -> None:
|
|
session = _make_session(session_id=_SESSION_ID)
|
|
context.mock_service.get.return_value = session
|
|
context.mock_service.export_session.return_value = session.as_export_dict()
|
|
context.session_id = _SESSION_ID
|
|
|
|
|
|
@given("there is an existing export file")
|
|
def step_existing_export_file(context: Context) -> None:
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write("{}")
|
|
context.existing_export_path = path
|
|
context._cleanup_paths.append(path)
|
|
|
|
|
|
@when("I run session CLI export with no output file")
|
|
def step_export_stdout(context: Context) -> None:
|
|
context.result = context.runner.invoke(session_app, ["export", context.session_id])
|
|
|
|
|
|
@when("I run session CLI export with --output-format json")
|
|
def step_export_output_format_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["export", context.session_id, "--output-format", "json"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI export with --output to a temp file")
|
|
def step_export_to_file(context: Context) -> None:
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
os.close(fd)
|
|
os.unlink(path) # Remove so export can create it
|
|
context.export_path = path
|
|
context._cleanup_paths.append(path)
|
|
context.result = context.runner.invoke(
|
|
session_app, ["export", context.session_id, "--output", path]
|
|
)
|
|
|
|
|
|
@when("I run session CLI export to an existing file without --force")
|
|
def step_export_no_force(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["export", context.session_id, "--output", context.existing_export_path],
|
|
)
|
|
|
|
|
|
@when("I run session CLI export to an existing file with --force")
|
|
def step_export_with_force(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
[
|
|
"export",
|
|
context.session_id,
|
|
"--output",
|
|
context.existing_export_path,
|
|
"--force",
|
|
],
|
|
)
|
|
|
|
|
|
@when("I run session CLI export with a non-existent session ID")
|
|
def step_export_nonexistent(context: Context) -> None:
|
|
context.mock_service.export_session.side_effect = SessionNotFoundError(
|
|
"Session not found"
|
|
)
|
|
context.result = context.runner.invoke(session_app, ["export", "NONEXISTENT"])
|
|
|
|
|
|
@then("the session CLI export should succeed")
|
|
def step_export_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the exported file should exist")
|
|
def step_exported_file_exists(context: Context) -> None:
|
|
assert os.path.exists(context.export_path), (
|
|
f"Export file not found: {context.export_path}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there is a valid session export file")
|
|
def step_valid_export_file(context: Context) -> None:
|
|
session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
|
|
export_data = session.as_export_dict()
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
with os.fdopen(fd, "w") as fh:
|
|
json.dump(export_data, fh, default=str)
|
|
context.import_path = path
|
|
context._cleanup_paths.append(path)
|
|
|
|
imported = _make_session(session_id=str(ULID()), actor_name="openai/gpt-4")
|
|
context.mock_service.import_session.return_value = imported
|
|
|
|
|
|
@given("there is a corrupt session export file")
|
|
def step_corrupt_export_file(context: Context) -> None:
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
with os.fdopen(fd, "w") as fh:
|
|
json.dump({"schema_version": "1.0", "bad": "data"}, fh)
|
|
context.corrupt_path = path
|
|
context._cleanup_paths.append(path)
|
|
context.mock_service.import_session.side_effect = SessionImportError(
|
|
"Checksum verification failed"
|
|
)
|
|
|
|
|
|
@given("there is an invalid JSON file")
|
|
def step_invalid_json_file(context: Context) -> None:
|
|
fd, path = tempfile.mkstemp(suffix=".json")
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write("{not valid json")
|
|
context.invalid_json_path = path
|
|
context._cleanup_paths.append(path)
|
|
|
|
|
|
@when("I run session CLI import with the export file")
|
|
def step_import_valid(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["import", "--input", context.import_path]
|
|
)
|
|
|
|
|
|
@when("I run session CLI import with the export file and --format json")
|
|
def step_import_valid_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["import", "--input", context.import_path, "--format", "json"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI import with a non-existent file")
|
|
def step_import_nonexistent(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["import", "--input", "/tmp/nonexistent_session_file.json"]
|
|
)
|
|
|
|
|
|
@when("I run session CLI import with the corrupt file")
|
|
def step_import_corrupt(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["import", "--input", context.corrupt_path]
|
|
)
|
|
|
|
|
|
@when("I run session CLI import with the invalid JSON file")
|
|
def step_import_invalid_json(context: Context) -> None:
|
|
context.result = context.runner.invoke(
|
|
session_app, ["import", "--input", context.invalid_json_path]
|
|
)
|
|
|
|
|
|
@then("the session CLI import should succeed")
|
|
def step_import_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("there is a mocked session for tell")
|
|
def step_session_for_tell(context: Context) -> None:
|
|
# Session with actor so tell can proceed without --actor flag.
|
|
session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
|
|
context.mock_service.get.return_value = session
|
|
context.mock_service.get_messages.return_value = []
|
|
context.session_id = _SESSION_ID
|
|
# Configure the mock workflow to return a canned TellResult.
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=_SESSION_ID,
|
|
user_message="Hello, world",
|
|
assistant_message="Acknowledged: Hello, world",
|
|
input_tokens=10,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with a prompt "{prompt}"')
|
|
def step_tell_prompt(context: Context, prompt: str) -> None:
|
|
# Update the mock workflow result to reflect the current prompt.
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Acknowledged: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, prompt],
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with --actor "{actor}" and prompt "{prompt}"')
|
|
def step_tell_with_actor(context: Context, actor: str, prompt: str) -> None:
|
|
# Update the mock workflow result to reflect actor and prompt.
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"[{actor}] Acknowledged: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "--actor", actor, prompt],
|
|
)
|
|
|
|
|
|
@when("I run session CLI tell to a non-existent session")
|
|
def step_tell_nonexistent(context: Context) -> None:
|
|
# Configure the workflow to raise SessionNotFoundError.
|
|
context.mock_workflow.tell.side_effect = SessionNotFoundError("Session not found")
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", "NONEXISTENT", "Hello"],
|
|
)
|
|
|
|
|
|
@then("the session CLI tell should succeed")
|
|
def step_tell_succeeds(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the session CLI output should contain "{text}"')
|
|
def step_output_contains(context: Context, text: str) -> None:
|
|
assert text in context.result.output, (
|
|
f"Expected '{text}' in output:\n{context.result.output}"
|
|
)
|
|
|
|
|
|
@then('the session CLI output should not contain "{text}"')
|
|
def step_output_not_contains(context: Context, text: str) -> None:
|
|
assert text not in context.result.output, (
|
|
f"Expected '{text}' NOT in output, but it was found:\n{context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the session CLI output should be valid JSON")
|
|
def step_output_valid_json(context: Context) -> None:
|
|
try:
|
|
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 session CLI JSON should contain "{key}"')
|
|
def step_json_contains_key(context: Context, key: str) -> None:
|
|
parsed = json.loads(context.result.output)
|
|
data = _unwrap_envelope(parsed)
|
|
assert key in data, f"Key '{key}' not found in JSON: {data}"
|
|
|
|
|
|
@then("the session CLI JSON token usage should include counts")
|
|
def step_json_show_token_usage_counts(context: Context) -> None:
|
|
parsed = json.loads(context.result.output)
|
|
data = _unwrap_envelope(parsed)
|
|
token_usage = data.get("token_usage")
|
|
assert isinstance(token_usage, dict), (
|
|
f"token_usage missing or not an object: {token_usage}"
|
|
)
|
|
_assert_token_usage_counts_are_ints(token_usage)
|
|
|
|
|
|
@then("the session CLI JSON list entries should match the documented contract")
|
|
def step_json_list_contract(context: Context) -> None:
|
|
parsed = json.loads(context.result.output)
|
|
data = _unwrap_envelope(parsed)
|
|
sessions = data.get("sessions")
|
|
assert isinstance(sessions, list), f"sessions missing or not a list: {sessions}"
|
|
for index, session in enumerate(sessions):
|
|
assert isinstance(session, dict), (
|
|
f"session entry at index {index} is not an object: {session!r}"
|
|
)
|
|
expected_keys = {"id", "name", "actor", "messages", "updated"}
|
|
actual_keys = set(session.keys())
|
|
missing = expected_keys - actual_keys
|
|
extra = actual_keys - expected_keys
|
|
assert not missing, (
|
|
f"session entry {index} missing keys {sorted(missing)}: {session!r}"
|
|
)
|
|
assert not extra, (
|
|
f"session entry {index} has undocumented keys {sorted(extra)}: {session!r}"
|
|
)
|
|
|
|
|
|
def _assert_token_usage_counts_are_ints(token_usage: dict[str, Any]) -> None:
|
|
input_tokens = token_usage.get("input_tokens")
|
|
output_tokens = token_usage.get("output_tokens")
|
|
assert isinstance(input_tokens, int), (
|
|
f"input_tokens should be int, got {input_tokens!r}"
|
|
)
|
|
assert isinstance(output_tokens, int), (
|
|
f"output_tokens should be int, got {output_tokens!r}"
|
|
)
|
|
assert input_tokens != "***REDACTED***", "input_tokens should not be redacted"
|
|
assert output_tokens != "***REDACTED***", "output_tokens should not be redacted"
|
|
|
|
|
|
@then('the session CLI JSON envelope message should be "{expected}"')
|
|
def step_json_envelope_message(context: Context, expected: str) -> None:
|
|
parsed = json.loads(context.result.output)
|
|
assert isinstance(parsed, dict), (
|
|
f"Expected envelope dict, got {type(parsed)}: {context.result.output}"
|
|
)
|
|
messages = parsed.get("messages")
|
|
assert isinstance(messages, list), (
|
|
f"Expected 'messages' list, got {type(messages)}: {messages}"
|
|
)
|
|
assert messages, "Envelope messages should not be empty"
|
|
first = messages[0]
|
|
assert isinstance(first, dict), f"Message entry should be dict, got {type(first)}"
|
|
actual = first.get("text")
|
|
assert actual == expected, (
|
|
f"Expected message text '{expected}', got '{actual}' in {messages}"
|
|
)
|
|
|
|
|
|
@then("the session CLI should exit with error")
|
|
def step_exit_with_error(context: Context) -> None:
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit code, got {context.result.exit_code}: "
|
|
f"{context.result.output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tell format flag step definitions (issue #10466)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I run session CLI tell with --format json and prompt "{prompt}"')
|
|
def step_tell_with_format_json(context: Context, prompt: str) -> None:
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Response: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "--format", "json", prompt],
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with --format yaml and prompt "{prompt}"')
|
|
def step_tell_with_format_yaml(context: Context, prompt: str) -> None:
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Response: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "--format", "yaml", prompt],
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with --format plain and prompt "{prompt}"')
|
|
def step_tell_with_format_plain(context: Context, prompt: str) -> None:
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Response: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "--format", "plain", prompt],
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with --format table and prompt "{prompt}"')
|
|
def step_tell_with_format_table(context: Context, prompt: str) -> None:
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Response: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "--format", "table", prompt],
|
|
)
|
|
|
|
|
|
@when('I run session CLI tell with short format flag {fmt} and prompt "{prompt}"')
|
|
def step_tell_with_short_format_flag(context: Context, fmt: str, prompt: str) -> None:
|
|
"""Run tell with the -f shorthand for --format."""
|
|
context.mock_workflow.tell.return_value = TellResult(
|
|
session_id=context.session_id,
|
|
user_message=prompt,
|
|
assistant_message=f"Response: {prompt}",
|
|
input_tokens=len(prompt) // 4,
|
|
output_tokens=5,
|
|
cost=0.0,
|
|
duration_ms=0.0,
|
|
tool_calls_count=0,
|
|
)
|
|
context.result = context.runner.invoke(
|
|
session_app,
|
|
["tell", "--session", context.session_id, "-f", fmt, prompt],
|
|
)
|
|
|
|
|
|
@then("the session CLI tell JSON should contain a data envelope")
|
|
def step_tell_json_has_envelope(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
parsed = json.loads(context.result.output)
|
|
envelope_keys = {"command", "status", "exit_code", "data", "timing", "messages"}
|
|
assert envelope_keys.issubset(parsed.keys()), (
|
|
f"JSON output missing envelope keys: {envelope_keys - set(parsed.keys())}. "
|
|
f"Got keys: {parsed.keys()}"
|
|
)
|
|
|
|
|
|
@then("the session CLI tell output should be valid YAML")
|
|
def step_tell_output_valid_yaml(context: Context) -> None:
|
|
import yaml
|
|
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
try:
|
|
yaml.safe_load(context.result.output)
|
|
except yaml.YAMLError as exc:
|
|
raise AssertionError(f"Output is not valid YAML: {exc}") from exc
|
|
|
|
|
|
@then('the session CLI tell output should contain "session_id:"')
|
|
def step_tell_plain_contains_session_id(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
assert "session_id:" in context.result.output, (
|
|
f"Expected 'session_id:' in plain output, got: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the session CLI tell output should contain a table border character")
|
|
def step_tell_table_has_border(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
# ASCII table uses border characters like | and +-+
|
|
has_border = "|" in context.result.output or "+" in context.result.output
|
|
assert has_border, (
|
|
f"Expected table border character in output, got: {context.result.output}"
|
|
)
|