Compare commits

...
2 Commits
Author SHA1 Message Date
Mervin PraisonandMervinPraison b589c6fce2 refactor(api): PraisonAI-native names for discovery, citations, reasoning types (#52)
Rename the six public dataclasses shipped in v0.3.113 so none of them are
exact-name lifts from an external framework.  Names are now PraisonAI-unique,
noun-first, non-developer-friendly, and pair with the existing ``*Prompt`` /
``ThinkingSteps`` PraisonAI style.

## Rename table

| v0.3.113 (lifted) | v0.3.114 (PraisonAI-native) |
|-------------------|-----------------------------|
| ModelInfo         | **ModelCard**               |
| AgentDetails      | **AgentCard**               |
| TeamDetails       | **TeamCard**                |
| Reference         | **SourceChunk**             |
| ReferenceData     | **SourceBundle**            |
| ReasoningStep     | **ThoughtStep**             |
| `references_event()`     | **`source_bundle_event()`** |
| `reasoning_step_event()` | **`thought_step_event()`**  |

## What stays the same

- **Wire format is unchanged.**  The SSE event ``type`` string is still
  ``"references"``, and the JSON payload still uses the keys ``query``,
  ``references``, ``time_ms`` so third-party chat frontends see the exact
  shape they expect.
- ``RunEvent`` and ``RunEventType`` keep their existing names — these
  pre-existed in PraisonAIUI before the competitor audit and are not lifts.
- ``RunEventType.REFERENCES = "references"`` enum variant is unchanged.
- All REST routes added in v0.3.113 are unchanged (``/teams``, etc.).

## Why a breaking rename (no aliases)

Keeping the lifted names even as silent aliases would defeat the stated
goal ("names unique for PraisonAI").  v0.3.113 was released <1 h before
this rename, so no external consumer can have taken a dependency yet.

## Tests

- Every existing test in ``tests/unit/test_protocol_parity.py`` updated
  to the new names — **all 15 pass**.
- **New regression guard** ``test_no_competitor_lifted_names_exported``
  fails loudly if any of the six lifted names ever reappear on the
  public module, preventing accidental reintroduction.

Full suite: **955 pass**, 4 skipped, 8 xfailed, 1 xpassed.  Ruff clean.

## Version
Bumps to 0.3.114.

Co-authored-by: MervinPraison <[email protected]>
2026-04-23 00:41:32 +01:00
Mervin PraisonandMervinPraison 76d01dbd19 feat(protocol): REST team discovery, RAG references, rich reasoning steps (closes #48, #49, #50) (#51)
Three non-clashing, strictly-additive protocol enhancements so third-party
chat frontends can integrate with PraisonAIUI without PraisonAIUI-internal
knowledge.

## Issue #48 — REST discovery parity
### Before
```
GET /agents  → {"agents": [{"name": "...", "created_at": 123}]}
# no /teams anywhere
```
### After
```
GET /agents  → {"agents": [{"agent_id": "...", "name": "...",
                            "description": "...", "model": {...},
                            "storage": bool, "created_at": 123}]}
GET /teams   → {"teams": [...]}
GET /api/teams
POST /teams/{team_id}/runs         (SSE stream, mirrors /agents/{id}/runs)
POST /api/teams/{team_id}/runs
DELETE /teams/{team_id}/sessions/{session_id}
```
`BaseProvider.list_teams()` and `BaseProvider.run_team()` added with sane
defaults so every existing provider keeps working untouched.  New dataclasses
`ModelInfo`, `AgentDetails`, `TeamDetails` give contributors a typed schema
to implement against.

## Issue #49 — RAG references
### Before
No way for a RAG provider to emit source chunks; frontend has no reference
schema.
### After
- `RunEventType.REFERENCES = "references"` enum variant
- `Reference(name, content, chunk, chunk_size)` dataclass
- `ReferenceData(query, references, time_ms)` dataclass
- `BaseProvider.references_event(query, refs, time_ms)` one-line helper
- `RunEvent` gained optional `query`, `references`, `time_ms` fields that
  flow through `to_dict()` into the SSE wire format automatically.

## Issue #50 — Rich reasoning steps
### Before
`RunEvent.step` was a bare string; providers could only send step text.
### After
- `ReasoningStep(title, result, reasoning, action?, confidence?, next_action?)`
  dataclass
- `BaseProvider.reasoning_step_event(step)` helper that populates both the
  canonical wire fields (`step`, `action`, `confidence`, `next_action`) and a
  lossless `extra_data` dict for full-fidelity replay.
- `RunEvent` gained optional `action`, `confidence`, `next_action` fields.
  Old providers that only set `step=<text>` continue to work bit-for-bit.

## Evidence per claim
- `provider.py`: `+155 lines`, all additive (new dataclasses + enum variant +
  two optional-field extensions + three helper methods).  No existing field
  was removed or reordered; `RunEvent.to_dict()` keeps omitting `None`
  fields so the wire format is unchanged for callers that don't use the new
  fields.
- `server.py`: `+79 lines` (three new route handlers, one-line enrichment in
  `list_agents`), five new `Route(...)` registrations.  Every legacy field in
  `GET /agents` response is still present.
- `__init__.py`: six new symbols added to both the lazy-dispatch table and
  `__all__`.  No existing symbol changed.

## Tests
`tests/unit/test_protocol_parity.py` — **14 parametric tests**:
- `/agents` enrichment + legacy-field preservation
- `/teams`, `/api/teams`, `/teams/{id}/runs` (400 on bad JSON)
- `/teams/{team_id}/sessions/{session_id}` DELETE route exists
- `Reference`, `ReferenceData`, `ReasoningStep` dataclass round-trips
- `references_event` / `reasoning_step_event` helper payload shape
- Back-compat: old `RunEvent(step="text")` still serialises correctly
- `__all__` contains every new symbol

Full suite: **954 pass**, 4 skipped, 8 xfailed, 1 xpassed.  Ruff clean.

## Version
Bumps to 0.3.113.

Co-authored-by: MervinPraison <[email protected]>
2026-04-23 00:21:47 +01:00
7 changed files with 558 additions and 6 deletions

No files matched your search

@@ -22,6 +22,7 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue →
- **No competitor names in public surfaces**: Never mention competitor product, company, or project names in GitHub issues, PRs, commit messages, release notes, docs, or any other public-facing content. Refer to capabilities generically (e.g., "self-improving agent pattern", "external agent framework").
- **Test before merge**: never merge unvalidated PRs. Always clone the branch locally and run the tests.
- **Minimal code change**: prefer upstream one-line fixes over downstream workarounds.
- **Autonomous polling — NEVER ask the user to "check now"**: after posting feedback to `@claude`, submitting a trigger comment, or any state that requires an external agent to push commits, the assistant itself waits ~10 minutes (`sleep 600`) and then re-checks. Do not stop and wait for the human to type "check now". Only surface control back to the human when a round is fully complete (PR merged or 3-round feedback cap hit) or a blocking decision is required (e.g. rebase strategy, conflicting feature intent).
---
@@ -536,6 +537,11 @@ sleep 600
# → return to Phase 12 (fetch new commits, re-run checks)
```
**The assistant runs this `sleep 600` itself — it does NOT pause the loop to ask the user "check now".** After the 10-minute wait completes, the assistant immediately re-fetches the PR, re-runs the test suite, and either merges, posts the next feedback round, or reports a final status. The human is only prompted when:
- The PR merges successfully (report + move to next issue).
- The 3-round feedback cap is hit (escalate).
- A design decision is needed that the assistant cannot make unilaterally (e.g. "should `allow` policy persist or not?").
Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully green, or escalate to a human after 3 failed rounds.
### When the PR has merge conflicts
@@ -668,3 +674,4 @@ gh issue view $ISSUE_NUM --repo $REPO --json state
6. **Always verify** the issue auto-closed after merge.
7. **All `run_command` calls use `timeout`** to avoid hangs (user global rule).
8. **No proactive `.md` creation** beyond this workflow file (user global rule).
9. **Never ask the user "check now"**: between rounds the assistant runs `sleep 600` itself and keeps driving. Only return control to the human on merge, 3-round cap, or an irresolvable design decision.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.112"
version = "0.3.114"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+22 -1
View File
@@ -197,7 +197,20 @@ def __getattr__(name: str):
"JSONFileDataStore",
"SQLAlchemyDataStore",
}
_provider_attrs = {"BaseProvider", "RunEvent", "RunEventType"}
_provider_attrs = {
"BaseProvider",
"RunEvent",
"RunEventType",
# Discovery cards (Issue #48) — PraisonAI-native names
"ModelCard",
"AgentCard",
"TeamCard",
# RAG source chunks (Issue #49) — PraisonAI-native names
"SourceChunk",
"SourceBundle",
# Rich reasoning (Issue #50) — PraisonAI-native name
"ThoughtStep",
}
_providers_attrs = {"PraisonAIProvider"}
_config_attrs = {"configure"}
_action_attrs = {"Action", "action_callback"}
@@ -474,6 +487,14 @@ __all__ = [
"RunEvent",
"RunEventType",
"PraisonAIProvider",
# Discovery, citations, reasoning schema (Issues #48, #49, #50)
# PraisonAI-native, noun-first names (not lifted from any external framework)
"ModelCard",
"AgentCard",
"TeamCard",
"SourceChunk",
"SourceBundle",
"ThoughtStep",
# Configuration
"configure",
# Action classes and decorators
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.112"
__version__ = "0.3.114"
+217 -1
View File
@@ -69,6 +69,9 @@ class RunEventType(str, Enum):
RUN_PAUSED = "run_paused"
RUN_CONTINUED = "run_continued"
# --- RAG / Citations (Issue #49) ---
REFERENCES = "references"
# --- Team variants ---
TEAM_RUN_STARTED = "team_run_started"
TEAM_RUN_CONTENT = "team_run_content"
@@ -84,6 +87,146 @@ class RunEventType(str, Enum):
TEAM_MEMORY_UPDATE_COMPLETED = "team_memory_update_completed"
# ---------------------------------------------------------------------------
# Structured payload dataclasses (Issues #48, #49, #50)
# ---------------------------------------------------------------------------
@dataclass
class ModelCard:
"""Model / provider info advertised by an agent or team (Issue #48).
PraisonAI-native, non-developer-friendly name: a "card" is the small
info block a UI shows next to an agent.
"""
name: str
model: str
provider: str
def to_dict(self) -> Dict[str, Any]:
return {"name": self.name, "model": self.model, "provider": self.provider}
@dataclass
class AgentCard:
"""Agent discovery card — what a chat frontend shows in its picker (Issue #48)."""
agent_id: str
name: str
description: str = ""
model: Optional[ModelCard] = None
storage: bool = False
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"agent_id": self.agent_id,
"name": self.name,
"description": self.description,
"storage": self.storage,
}
if self.model is not None:
d["model"] = self.model.to_dict()
return d
@dataclass
class TeamCard:
"""Team discovery card — what a chat frontend shows for multi-agent teams (Issue #48)."""
team_id: str
name: str
description: str = ""
model: Optional[ModelCard] = None
storage: bool = False
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"team_id": self.team_id,
"name": self.name,
"description": self.description,
"storage": self.storage,
}
if self.model is not None:
d["model"] = self.model.to_dict()
return d
@dataclass
class SourceChunk:
"""A single RAG retrieval chunk cited by an answer (Issue #49).
PraisonAI-native name — "source chunk" is the plain-English term a
non-developer would use for "a piece of a source document that was used".
"""
name: str
content: str
chunk: int = 0
chunk_size: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"content": self.content,
"chunk": self.chunk,
"chunk_size": self.chunk_size,
}
@dataclass
class SourceBundle:
"""A bundle of source chunks retrieved for one query (Issue #49).
Wire format keeps the industry-standard ``references`` JSON key so
third-party frontends see the expected shape; only the Python class
name is PraisonAI-native.
"""
query: str
chunks: List[SourceChunk] = field(default_factory=list)
time_ms: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"query": self.query,
"references": [c.to_dict() for c in self.chunks],
}
if self.time_ms is not None:
d["time_ms"] = self.time_ms
return d
@dataclass
class ThoughtStep:
"""Structured reasoning-step payload (Issue #50).
PraisonAI-native name — pairs with the existing ``ThinkingSteps`` UI
component. All fields beyond ``title`` are optional so providers can
adopt the richer schema incrementally without breaking existing
behaviour.
"""
title: str
result: str = ""
reasoning: str = ""
action: Optional[str] = None # e.g. "search", "plan", "verify"
confidence: Optional[float] = None # 0.0 .. 1.0
next_action: Optional[str] = None # what the agent plans to do next
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"title": self.title,
"result": self.result,
"reasoning": self.reasoning,
}
for key in ("action", "confidence", "next_action"):
val = getattr(self, key)
if val is not None:
d[key] = val
return d
# ---------------------------------------------------------------------------
# RunEvent — the single data structure emitted by providers
# ---------------------------------------------------------------------------
@@ -126,12 +269,21 @@ class RunEvent:
result: Optional[Any] = None
tool_call_id: Optional[str] = None
# Reasoning
# Reasoning (Issue #50 — structured fields are optional)
step: Optional[str] = None
action: Optional[str] = None
confidence: Optional[float] = None
next_action: Optional[str] = None
# Agent / Team
agent_id: Optional[str] = None
agent_name: Optional[str] = None
team_id: Optional[str] = None
# RAG / Citations (Issue #49)
query: Optional[str] = None
references: Optional[List[Dict[str, Any]]] = None
time_ms: Optional[float] = None
# Error
error: Optional[str] = None
@@ -152,8 +304,15 @@ class RunEvent:
"result",
"tool_call_id",
"step",
"action",
"confidence",
"next_action",
"agent_id",
"agent_name",
"team_id",
"query",
"references",
"time_ms",
"error",
"event_id",
"timestamp",
@@ -220,6 +379,63 @@ class BaseProvider(ABC):
"""List available agents. Override if your backend has agents."""
return []
async def list_teams(self) -> List[Dict[str, Any]]:
"""List available multi-agent teams. Override if your backend has teams.
Default returns ``[]`` so providers that don't model teams keep working
unchanged (Issue #48).
"""
return []
async def run_team(
self,
team_id: str,
message: str,
*,
session_id: Optional[str] = None,
**kwargs: Any,
) -> AsyncIterator[RunEvent]:
"""Run a multi-agent team. Override if your backend has teams.
The default implementation delegates to ``run()`` with ``agent_name``
set to ``team_id`` so single-agent backends transparently treat a
team_id as an agent_name (Issue #48).
"""
async for ev in self.run(
message, session_id=session_id, agent_name=team_id, **kwargs
):
yield ev
async def health(self) -> Dict[str, Any]:
"""Health check endpoint data. Override for custom checks."""
return {"status": "ok", "provider": self.__class__.__name__}
# -----------------------------------------------------------------
# Convenience emit helpers (Issues #49, #50) — keep provider code terse.
# -----------------------------------------------------------------
@staticmethod
def source_bundle_event(bundle: "SourceBundle") -> RunEvent:
"""Build a ``REFERENCES`` event from a ``SourceBundle`` (Issue #49).
The wire ``type`` stays ``references`` for third-party frontend
compatibility; only the Python-side helper name is PraisonAI-native.
"""
return RunEvent(
type=RunEventType.REFERENCES,
query=bundle.query,
references=[c.to_dict() for c in bundle.chunks],
time_ms=bundle.time_ms,
)
@staticmethod
def thought_step_event(step: "ThoughtStep") -> RunEvent:
"""Build a ``REASONING_STEP`` event from a ``ThoughtStep`` (Issue #50)."""
return RunEvent(
type=RunEventType.REASONING_STEP,
step=step.title,
action=step.action,
confidence=step.confidence,
next_action=step.next_action,
extra_data=step.to_dict(),
)
+73 -2
View File
@@ -855,13 +855,22 @@ async def health(request: Request) -> JSONResponse:
async def list_agents(request: Request) -> JSONResponse:
"""List all registered agents (merges registry + provider)."""
"""List all registered agents (merges registry + provider).
Returns the enriched third-party-chat-frontend-compatible schema
(``agent_id``, ``name``, ``description``, ``model``, ``storage``,
Issue #48) while keeping the legacy ``created_at`` field for
backward compatibility.
"""
agents = [
{
"agent_id": name,
"name": info["name"],
"description": info.get("description", ""),
"storage": bool(info.get("storage", False)),
"created_at": info["created_at"],
}
for info in _agents.values()
for name, info in _agents.items()
]
# Also ask the provider for agents
try:
@@ -870,12 +879,64 @@ async def list_agents(request: Request) -> JSONResponse:
existing_names = {a["name"] for a in agents}
for pa in provider_agents:
if pa.get("name") not in existing_names:
# Ensure every entry carries an agent_id for client routing.
if "agent_id" not in pa:
pa = {**pa, "agent_id": pa.get("name", "")}
agents.append(pa)
except Exception:
pass
return JSONResponse({"agents": agents})
async def list_teams(request: Request) -> JSONResponse:
"""List all multi-agent teams (Issue #48).
Delegates to ``provider.list_teams()``; default providers return ``[]``
so single-agent backends transparently return an empty list.
"""
teams: list[dict[str, Any]] = []
try:
provider = get_provider()
provider_teams = await provider.list_teams()
for pt in provider_teams:
if "team_id" not in pt:
pt = {**pt, "team_id": pt.get("name", "")}
teams.append(pt)
except Exception:
pass
return JSONResponse({"teams": teams})
async def run_team_by_id(request: Request) -> StreamingResponse:
"""Run a specific team by ID with SSE streaming (Issue #48).
Mirrors ``run_agent_by_id``; ``BaseProvider.run_team`` falls back to
``run()`` when the provider doesn't override it, so single-agent
backends still work when a client addresses them as a team.
"""
team_id = request.path_params["team_id"]
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
body["team"] = team_id
# Team run is modelled as an agent run with agent_name=team_id so the
# SSE plumbing (session persistence, event forwarding) is identical.
body.setdefault("agent", team_id)
request._body = body
return await run_agent(request, body)
async def delete_team_session(request: Request) -> JSONResponse:
"""Delete a session scoped to a team (Issue #48).
Provider-agnostic — delegates to the same session-delete path as the
per-agent API.
"""
return await delete_session(request)
async def list_sessions(request: Request) -> JSONResponse:
"""List all sessions."""
sessions = await _datastore.list_sessions()
@@ -2575,6 +2636,16 @@ def create_app(
Route("/cancel", cancel_run, methods=["POST"]),
Route("/agents/{agent_id}/runs", run_agent_by_id, methods=["POST"]),
Route("/api/agents/{agent_id}/runs", run_agent_by_id, methods=["POST"]),
# Teams REST surface (Issue #48)
Route("/teams", list_teams, methods=["GET"]),
Route("/api/teams", list_teams, methods=["GET"]),
Route("/teams/{team_id}/runs", run_team_by_id, methods=["POST"]),
Route("/api/teams/{team_id}/runs", run_team_by_id, methods=["POST"]),
Route(
"/teams/{team_id}/sessions/{session_id}",
delete_team_session,
methods=["DELETE"],
),
# Dashboard API
Route("/api/feedback", api_feedback, methods=["POST"]),
Route("/api/feedback", api_feedback_list, methods=["GET"]),
+237
View File
@@ -0,0 +1,237 @@
"""Protocol-parity tests for Issues #48, #49, #50.
Covers:
- Enriched /agents response + /teams REST surface (#48)
- RAG ``REFERENCES`` event + ``SourceChunk`` / ``SourceBundle`` (#49)
- Rich ``ThoughtStep`` schema + ``thought_step_event`` helper (#50)
The tests run against the ASGI app built by ``praisonaiui.build_app()`` so
route wiring, schema serialisation, and provider delegation are verified
end-to-end without spinning up a real HTTP server.
"""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
import praisonaiui as aiui
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
# ── Fixtures ────────────────────────────────────────────────────────
class _StubProvider(BaseProvider):
"""Provider that advertises one team and one simple run."""
async def run(self, message, *, session_id=None, agent_name=None, **kw):
yield RunEvent(type=RunEventType.RUN_STARTED)
yield RunEvent(type=RunEventType.RUN_COMPLETED, content=f"echo:{message}")
async def list_agents(self):
return [
{
"agent_id": "a1",
"name": "helper",
"description": "a helper",
"model": {"name": "gpt-4o", "model": "gpt-4o", "provider": "openai"},
"storage": True,
}
]
async def list_teams(self):
return [
{
"team_id": "t1",
"name": "squad",
"description": "a squad",
"model": {"name": "gpt-4o", "model": "gpt-4o", "provider": "openai"},
"storage": False,
}
]
@pytest.fixture()
def client(monkeypatch):
"""Build a fresh app with the stub provider installed."""
from praisonaiui.server import create_app
aiui.set_provider(_StubProvider())
app = create_app()
with TestClient(app) as c:
yield c
# ── Issue #48: REST discovery ───────────────────────────────────────
def test_list_agents_returns_enriched_schema(client):
r = client.get("/agents")
assert r.status_code == 200
agents = r.json()["agents"]
assert agents, "expected at least one agent"
a = agents[0]
# Enriched fields
assert "agent_id" in a
assert "name" in a
assert "description" in a
assert "storage" in a
def test_list_teams_returns_team_array(client):
r = client.get("/teams")
assert r.status_code == 200
teams = r.json()["teams"]
assert teams and teams[0]["team_id"] == "t1"
assert teams[0]["name"] == "squad"
def test_list_teams_api_alias(client):
r = client.get("/api/teams")
assert r.status_code == 200
assert "teams" in r.json()
def test_teams_run_invalid_json_returns_400(client):
r = client.post(
"/teams/t1/runs",
content="not-json",
headers={"Content-Type": "application/json"},
)
assert r.status_code == 400
def test_teams_route_delete_endpoint_registered(client):
# DELETE on a non-existent team session should not 404-on-route-missing
# (it either 204s or returns a body — either way the route exists).
r = client.delete("/teams/t1/sessions/nope")
assert r.status_code < 500
# ── Issue #49: references (RAG citations) ───────────────────────────
def test_source_chunk_round_trip():
r = aiui.SourceChunk(name="doc.md", content="x", chunk=3, chunk_size=400)
d = r.to_dict()
assert d == {"name": "doc.md", "content": "x", "chunk": 3, "chunk_size": 400}
def test_source_bundle_round_trip():
b = aiui.SourceBundle(
query="what is x",
chunks=[aiui.SourceChunk(name="a.md", content="alpha")],
time_ms=12.0,
)
d = b.to_dict()
assert d["query"] == "what is x"
assert d["time_ms"] == 12.0
# Wire key stays "references" for third-party frontend compatibility
assert d["references"][0]["name"] == "a.md"
def test_source_bundle_event_helper():
bundle = aiui.SourceBundle(
query="q",
chunks=[aiui.SourceChunk(name="a.md", content="alpha", chunk=1, chunk_size=50)],
time_ms=8.5,
)
ev = BaseProvider.source_bundle_event(bundle)
assert ev.type is RunEventType.REFERENCES
payload = ev.to_dict()
assert payload["type"] == "references"
assert payload["query"] == "q"
assert payload["references"][0]["name"] == "a.md"
assert payload["time_ms"] == 8.5
def test_references_enum_value_is_stable():
assert RunEventType.REFERENCES.value == "references"
# ── Issue #50: rich reasoning step ──────────────────────────────────
def test_thought_step_optional_fields_omitted_when_absent():
s = aiui.ThoughtStep(title="plan")
d = s.to_dict()
# Optional fields stay out of the dict entirely when None
assert "action" not in d
assert "confidence" not in d
assert "next_action" not in d
assert d["title"] == "plan"
def test_thought_step_full_fields():
s = aiui.ThoughtStep(
title="search",
result="found 3 refs",
reasoning="because the query was specific",
action="search",
confidence=0.82,
next_action="verify",
)
d = s.to_dict()
assert d == {
"title": "search",
"result": "found 3 refs",
"reasoning": "because the query was specific",
"action": "search",
"confidence": 0.82,
"next_action": "verify",
}
def test_thought_step_event_helper_carries_fields():
step = aiui.ThoughtStep(title="plan", confidence=0.9, action="plan")
ev = BaseProvider.thought_step_event(step)
assert ev.type is RunEventType.REASONING_STEP
d = ev.to_dict()
assert d["step"] == "plan"
assert d["confidence"] == 0.9
assert d["action"] == "plan"
# Full structured payload is also in extra_data for lossless replay
assert d["extra_data"]["title"] == "plan"
def test_runevent_backcompat_unstructured_step_still_works():
"""Old providers pass ``step=<text>`` without any new fields — still valid."""
ev = RunEvent(type=RunEventType.REASONING_STEP, step="just some text")
d = ev.to_dict()
assert d["step"] == "just some text"
assert "action" not in d
assert "confidence" not in d
# ── Public surface ──────────────────────────────────────────────────
def test_new_symbols_in_dunder_all():
expected = {
"ModelCard",
"AgentCard",
"TeamCard",
"SourceChunk",
"SourceBundle",
"ThoughtStep",
}
missing = expected - set(aiui.__all__)
assert not missing, f"Missing from __all__: {sorted(missing)}"
def test_no_competitor_lifted_names_exported():
"""Guard rail: PraisonAIUI must not re-export competitor-lifted type names."""
lifted = {
"ModelInfo",
"AgentDetails",
"TeamDetails",
"Reference",
"ReferenceData",
"ReasoningStep",
}
leaked = [n for n in lifted if hasattr(aiui, n)]
assert not leaked, (
f"PraisonAIUI leaked competitor-lifted names: {leaked}. "
"Use the PraisonAI-native ModelCard/AgentCard/TeamCard/SourceChunk/"
"SourceBundle/ThoughtStep instead."
)