Compare commits

...
9 Commits
53 changed files with 6926 additions and 143 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Update API Reference
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai/praisonai/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- '.github/workflows/update-api-md.yml'
permissions:
contents: write
jobs:
update-api-md:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
persist-credentials: true
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Generate api.md
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_api_md.py
- name: Check for changes
id: check_changes
run: |
if git diff --quiet api.md; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes to api.md"
else
echo "changes=true" >> $GITHUB_OUTPUT
echo "api.md has been updated"
git diff api.md | head -50
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: auto-update api.md [skip ci]"
file_pattern: 'api.md'
commit_user_name: "github-actions[bot]"
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
+2 -1
View File
@@ -83,4 +83,5 @@ issue_details.json
# MCP Registry tokens
.mcpregistry_*
*.log
*.log
.internal
+6 -1
View File
@@ -552,7 +552,7 @@ Methods:
Types:
```python
from praisonaiagents import AutoRagConfig, Citation, CodeExecutionStep, DeepResearchResponse, ExpandResult, ExpandStrategy, FileSearchCall, MCPCall, Provider, RagRetrievalPolicy, ReasoningStep, RewriteResult, RewriteStrategy, Task, WebSearchCall
from praisonaiagents import AutoRagConfig, Citation, CodeExecutionStep, DeepResearchResponse, ExpandResult, ExpandStrategy, FileSearchCall, MCPCall, Provider, RagRetrievalPolicy, ReasoningStep, RewriteResult, RewriteStrategy, Task, WebSearchCall, warmup
```
Methods:
@@ -569,6 +569,7 @@ Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/__init__.py">warmup</a>(include_litellm: bool = False, include_openai: bool = True) -> dict</code>
# Wrapper (praisonai)
@@ -731,6 +732,10 @@ Methods:
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise init <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise report <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai templates create <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai templates list <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai test info <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.3" \
"praisonai>=3.7.9" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.3" \
"praisonai>=3.7.9" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.3" \
"praisonai>=3.7.9" \
"praisonai[ui]" \
"praisonai[crewai]"
Whitespace-only changes.
@@ -447,14 +447,27 @@ class Agent:
# ─────────────────────────────────────────────────────────────────────
# Resolve OUTPUT param using unified resolver
# Supports: None, str preset, list [preset, overrides], OutputConfig
# DEFAULT: "silent" mode (zero overhead, fastest performance)
# Can be overridden by PRAISONAI_OUTPUT env var
# ─────────────────────────────────────────────────────────────────────
from ..config.presets import DEFAULT_OUTPUT_MODE
# Check for env var override
env_output = os.environ.get('PRAISONAI_OUTPUT', '').lower()
if env_output and env_output in OUTPUT_PRESETS and output is None:
output = env_output
# Use default output mode if not specified
if output is None:
output = DEFAULT_OUTPUT_MODE
_output_config = resolve(
value=output,
param_name="output",
config_class=OutputConfig,
presets=OUTPUT_PRESETS,
array_mode=ArrayMode.PRESET_OVERRIDE,
default=OutputConfig(),
default=OutputConfig(), # OutputConfig defaults to silent mode (zero overhead)
)
if _output_config:
verbose = _output_config.verbose
@@ -463,8 +476,24 @@ class Agent:
metrics = _output_config.metrics
reasoning_steps = _output_config.reasoning_steps
output_style = getattr(_output_config, 'style', None)
actions_trace = getattr(_output_config, 'actions_trace', False) # Default False (silent)
json_output = getattr(_output_config, 'json_output', False)
else:
verbose, markdown, stream, metrics, reasoning_steps = True, True, False, False, False
# Fallback defaults match silent mode (zero overhead)
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
actions_trace = False # No callbacks by default
json_output = False
# Enable actions output mode if configured
# This registers callbacks to capture tool calls and final output
if actions_trace:
try:
from ..output.actions import enable_actions_mode, is_actions_mode_enabled
if not is_actions_mode_enabled():
output_format = "jsonl" if json_output else "text"
enable_actions_mode(redact=True, use_color=True, format=output_format)
except ImportError:
pass # Actions module not available
# ─────────────────────────────────────────────────────────────────────
# Resolve EXECUTION param using unified resolver
@@ -1065,6 +1094,9 @@ Your Goal: {self.goal}
self._context_param = context # Store raw param for lazy init
self._context_manager = None # Lazy initialized on first use
self._context_manager_initialized = False
# Action trace mode - handled via display callbacks, not separate emitter
self._actions_trace = actions_trace
@property
def auto_memory(self):
@@ -391,10 +391,21 @@ class OutputConfig:
Consolidates: verbose, markdown, stream, metrics, reasoning_steps, output_style
DEFAULT: output="silent" (zero overhead, fastest performance)
Usage:
# Simple preset
# Default is silent mode (no output overhead, programmatic use)
Agent(instructions="...") # Uses output="silent"
# Actions mode (tool calls + final output trace)
Agent(output="actions")
# Verbose mode with Rich panels
Agent(output="verbose")
# JSON mode for piping
Agent(output="json")
# With config
Agent(output=OutputConfig(
verbose=True,
@@ -404,11 +415,11 @@ class OutputConfig:
reasoning_steps=True,
))
"""
# Verbosity
verbose: bool = True
# Verbosity - False by default for silent mode (fastest)
verbose: bool = False
# Formatting
markdown: bool = True
# Formatting - False by default for silent mode
markdown: bool = False
# Streaming
stream: bool = False
@@ -422,6 +433,14 @@ class OutputConfig:
# Output style (custom styling)
style: Optional[Any] = None
# Actions trace mode - shows tool calls, agent lifecycle, final output
# When True, registers callbacks and outputs to stderr
# DEFAULT: False for zero overhead (silent mode)
actions_trace: bool = False
# JSON output mode - emit JSONL events for piping
json_output: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -430,6 +449,8 @@ class OutputConfig:
"stream": self.stream,
"metrics": self.metrics,
"reasoning_steps": self.reasoning_steps,
"actions_trace": self.actions_trace,
"json_output": self.json_output,
}
@@ -41,6 +41,37 @@ MEMORY_URL_SCHEMES: Dict[str, str] = {
# =============================================================================
OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
# Silent preset - DEFAULT for SDK
# Zero overhead, no callbacks, no I/O on hot path
# Fastest performance for programmatic use
"silent": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": False,
},
# Actions preset - opt-in observability
# Shows action trace (tool calls, agent lifecycle) + final output
# Registers callbacks, outputs to stderr
"actions": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": True, # Special flag for action trace mode
},
# Plain preset - final output only, no action trace
"plain": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": False,
},
"minimal": {
"verbose": False,
"markdown": False,
@@ -69,13 +100,6 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"metrics": True,
"reasoning_steps": True,
},
"silent": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
},
# Streaming preset - enables streaming by default
"stream": {
"verbose": True,
@@ -84,8 +108,23 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"metrics": False,
"reasoning_steps": False,
},
# JSON preset - JSONL output for piping
"json": {
"verbose": False,
"markdown": False,
"stream": False,
"metrics": False,
"reasoning_steps": False,
"actions_trace": True,
"json_output": True, # Output as JSONL
},
}
# Default output mode - can be overridden by PRAISONAI_OUTPUT env var
# "silent" = zero overhead, fastest performance (DEFAULT)
# "actions" = tool call trace + final output (opt-in observability)
DEFAULT_OUTPUT_MODE = "silent"
# =============================================================================
# Execution Presets
@@ -649,6 +649,7 @@ class OpenAIClient:
chunks = []
first_token_emitted = False
last_content_time = None
ttft_logged = False
# If display function provided, use Live display
if display_fn:
@@ -669,6 +670,12 @@ class OpenAIClient:
full_response_text += content
last_content_time = time.perf_counter()
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (last_content_time - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
# Emit FIRST_TOKEN on first content
if _emit and not first_token_emitted:
stream_callback(StreamEvent(
@@ -715,11 +722,17 @@ class OpenAIClient:
for chunk in response_stream:
chunks.append(chunk)
if _emit and chunk.choices and chunk.choices[0].delta.content:
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
last_content_time = time.perf_counter()
if not first_token_emitted:
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (last_content_time - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
if _emit and not first_token_emitted:
stream_callback(StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=last_content_time,
@@ -1130,7 +1143,9 @@ class OpenAIClient:
if display_fn and console:
# When verbose (display_fn provided), use streaming for better UX
try:
with Live(display_fn("", start_time), console=console, refresh_per_second=4, transient=True) as live:
request_start_perf = time.perf_counter()
ttft_logged = False
with Live(display_fn("", start_time), console=console, refresh_per_second=10, transient=True) as live:
# Use streaming when display_fn is provided for progressive display
response_stream = self.create_completion(
messages=messages,
@@ -1148,6 +1163,11 @@ class OpenAIClient:
for chunk in response_stream:
chunks.append(chunk)
if chunk.choices[0].delta.content:
# Log TTFT on first token
if not ttft_logged:
ttft_ms = (time.perf_counter() - request_start_perf) * 1000
logging.debug(f"TTFT (Time To First Token): {ttft_ms:.0f}ms")
ttft_logged = True
full_response_text += chunk.choices[0].delta.content
live.update(display_fn(full_response_text, start_time))
@@ -0,0 +1,68 @@
"""
Trace Module for PraisonAI Agents.
Provides lightweight action tracing for the `output="actions"` mode.
Shows agent lifecycle events, tool calls, and final output without
verbose internal details.
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- NoOpSink is the default (zero overhead when not used)
- Disabled emitter has near-zero overhead
Usage:
from praisonaiagents import Agent
# Simple usage - shows action trace
agent = Agent(instructions="...", output="actions")
agent.start("Do something")
# Advanced - capture to file
from praisonaiagents.trace import ActionTraceConfig
agent = Agent(
instructions="...",
output="actions",
trace=ActionTraceConfig(
sink_type="jsonl",
file_path="trace.jsonl",
)
)
"""
__all__ = [
# Core types
"ActionEvent",
"ActionEventType",
"ActionTraceConfig",
# Sinks
"TraceSink",
"NoOpSink",
"ListSink",
# Emitter
"TraceEmitter",
# Redaction
"redact_dict",
"REDACT_KEYS",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("ActionEvent", "ActionEventType", "ActionTraceConfig"):
from .protocol import ActionEvent, ActionEventType, ActionTraceConfig
return locals()[name]
if name in ("TraceSink", "NoOpSink", "ListSink"):
from .protocol import TraceSink, NoOpSink, ListSink
return locals()[name]
if name == "TraceEmitter":
from .protocol import TraceEmitter
return TraceEmitter
if name in ("redact_dict", "REDACT_KEYS"):
from .redact import redact_dict, REDACT_KEYS
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,398 @@
"""
Action Trace Protocol for PraisonAI Agents.
Lightweight protocol for tracing agent actions. Uses only stdlib
to ensure zero dependencies and minimal overhead.
Schema Version: 1.0
"""
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# Schema version for forward compatibility
SCHEMA_VERSION = "1.0"
class ActionEventType(str, Enum):
"""Types of action events."""
AGENT_START = "agent_start"
AGENT_END = "agent_end"
TOOL_START = "tool_start"
TOOL_END = "tool_end"
ERROR = "error"
OUTPUT = "output"
@dataclass
class ActionEvent:
"""
Lightweight action trace event.
Represents a single action in the agent execution trace.
Designed for minimal memory footprint and fast serialization.
"""
event_type: str
timestamp: float
schema_version: str = SCHEMA_VERSION
agent_id: Optional[str] = None
agent_name: Optional[str] = None
tool_name: Optional[str] = None
tool_args: Optional[Dict[str, Any]] = None
tool_result_summary: Optional[str] = None
duration_ms: Optional[float] = None
status: Optional[str] = None # ok, error
error_message: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary, excluding None values."""
result = {
"schema_version": self.schema_version,
"event_type": self.event_type,
"timestamp": self.timestamp,
}
# Add optional fields only if set
if self.agent_id is not None:
result["agent_id"] = self.agent_id
if self.agent_name is not None:
result["agent_name"] = self.agent_name
if self.tool_name is not None:
result["tool_name"] = self.tool_name
if self.tool_args is not None:
result["tool_args"] = self.tool_args
if self.tool_result_summary is not None:
result["tool_result_summary"] = self.tool_result_summary
if self.duration_ms is not None:
result["duration_ms"] = self.duration_ms
if self.status is not None:
result["status"] = self.status
if self.error_message is not None:
result["error_message"] = self.error_message
if self.metadata:
result["metadata"] = self.metadata
return result
def to_json(self) -> str:
"""Convert to JSON string."""
return json.dumps(self.to_dict(), default=str)
@runtime_checkable
class TraceSink(Protocol):
"""
Protocol for trace event sinks.
Implementations receive action events and handle them
(e.g., print to stdout, write to file, send to server).
"""
def emit(self, event: ActionEvent) -> None:
"""Emit an action event."""
...
def flush(self) -> None:
"""Flush any buffered events."""
...
def close(self) -> None:
"""Close the sink and release resources."""
...
class NoOpSink:
"""
No-operation sink that discards all events.
Used as the default sink when tracing is disabled.
Has near-zero overhead.
"""
__slots__ = ()
def emit(self, event: ActionEvent) -> None:
"""Discard the event."""
pass
def flush(self) -> None:
"""No-op."""
pass
def close(self) -> None:
"""No-op."""
pass
class ListSink:
"""
Sink that collects events in a list.
Useful for testing and programmatic access to trace events.
"""
__slots__ = ("_events",)
def __init__(self):
self._events: List[ActionEvent] = []
def emit(self, event: ActionEvent) -> None:
"""Add event to the list."""
self._events.append(event)
def flush(self) -> None:
"""No-op for list sink."""
pass
def close(self) -> None:
"""No-op for list sink."""
pass
def get_events(self) -> List[ActionEvent]:
"""Get all collected events."""
return self._events.copy()
def clear(self) -> None:
"""Clear all collected events."""
self._events.clear()
@dataclass
class ActionTraceConfig:
"""
Configuration for action tracing.
Usage:
# Default (disabled)
config = ActionTraceConfig()
# Enable with stdout
config = ActionTraceConfig(enabled=True, sink_type="stdout")
# Enable with JSONL file
config = ActionTraceConfig(
enabled=True,
sink_type="jsonl",
file_path="trace.jsonl",
)
"""
enabled: bool = True
redact: bool = True
compact: bool = False
sink_type: str = "noop" # noop, stdout, jsonl, file
file_path: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"enabled": self.enabled,
"redact": self.redact,
"compact": self.compact,
"sink_type": self.sink_type,
"file_path": self.file_path,
}
class TraceEmitter:
"""
Emitter for action trace events.
Provides a high-level API for emitting trace events.
Handles redaction and sink management.
Usage:
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.agent_start("researcher")
emitter.tool_start("search", {"query": "AI"})
emitter.tool_end("search", duration_ms=100, status="ok")
emitter.agent_end("researcher", duration_ms=500)
"""
__slots__ = ("_sink", "_enabled", "_redact", "_agent_starts")
def __init__(
self,
sink: Optional[TraceSink] = None,
enabled: bool = True,
redact: bool = True,
):
self._sink = sink or NoOpSink()
self._enabled = enabled
self._redact = redact
self._agent_starts: Dict[str, float] = {} # Track agent start times
def _emit(self, event: ActionEvent) -> None:
"""Internal emit with enabled check."""
if not self._enabled:
return
self._sink.emit(event)
def _redact_args(self, args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Redact sensitive data from args if enabled."""
if args is None or not self._redact:
return args
from .redact import redact_dict
return redact_dict(args)
def agent_start(
self,
agent_name: str,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit agent start event."""
ts = time.time()
self._agent_starts[agent_name] = ts
self._emit(ActionEvent(
event_type=ActionEventType.AGENT_START.value,
timestamp=ts,
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def agent_end(
self,
agent_name: str,
agent_id: Optional[str] = None,
duration_ms: Optional[float] = None,
status: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit agent end event."""
ts = time.time()
# Calculate duration if not provided
if duration_ms is None and agent_name in self._agent_starts:
start_ts = self._agent_starts.pop(agent_name, None)
if start_ts:
duration_ms = (ts - start_ts) * 1000
self._emit(ActionEvent(
event_type=ActionEventType.AGENT_END.value,
timestamp=ts,
agent_name=agent_name,
agent_id=agent_id,
duration_ms=duration_ms,
status=status,
metadata=metadata or {},
))
def tool_start(
self,
tool_name: str,
tool_args: Optional[Dict[str, Any]] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit tool start event."""
self._emit(ActionEvent(
event_type=ActionEventType.TOOL_START.value,
timestamp=time.time(),
tool_name=tool_name,
tool_args=self._redact_args(tool_args),
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def tool_end(
self,
tool_name: str,
duration_ms: float,
status: str = "ok",
result_summary: Optional[str] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
error_message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit tool end event."""
self._emit(ActionEvent(
event_type=ActionEventType.TOOL_END.value,
timestamp=time.time(),
tool_name=tool_name,
duration_ms=duration_ms,
status=status,
tool_result_summary=result_summary,
agent_name=agent_name,
agent_id=agent_id,
error_message=error_message,
metadata=metadata or {},
))
def error(
self,
message: str,
tool_name: Optional[str] = None,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit error event."""
self._emit(ActionEvent(
event_type=ActionEventType.ERROR.value,
timestamp=time.time(),
error_message=message,
tool_name=tool_name,
agent_name=agent_name,
agent_id=agent_id,
status="error",
metadata=metadata or {},
))
def output(
self,
content: str,
agent_name: Optional[str] = None,
agent_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Emit final output event."""
# Truncate long output for summary
summary = content[:500] + "..." if len(content) > 500 else content
self._emit(ActionEvent(
event_type=ActionEventType.OUTPUT.value,
timestamp=time.time(),
tool_result_summary=summary,
agent_name=agent_name,
agent_id=agent_id,
metadata=metadata or {},
))
def flush(self) -> None:
"""Flush the sink."""
self._sink.flush()
def close(self) -> None:
"""Close the sink."""
self._sink.close()
# Global default emitter (NoOp by default)
_default_emitter: Optional[TraceEmitter] = None
def get_default_emitter() -> TraceEmitter:
"""Get the default trace emitter."""
global _default_emitter
if _default_emitter is None:
_default_emitter = TraceEmitter(sink=NoOpSink(), enabled=False)
return _default_emitter
def set_default_emitter(emitter: TraceEmitter) -> None:
"""Set the default trace emitter."""
global _default_emitter
_default_emitter = emitter
@@ -0,0 +1,149 @@
"""
Redaction utilities for action trace.
Provides deterministic redaction of sensitive data in tool arguments
and results. Uses only stdlib for zero dependencies.
"""
from typing import Any, Dict, Set
import re
# Keys that should always be redacted (case-insensitive matching)
REDACT_KEYS: Set[str] = {
# API keys and tokens
"api_key",
"apikey",
"api-key",
"secret",
"secret_key",
"secretkey",
"private_key",
"privatekey",
# Authentication
"password",
"passwd",
"pwd",
"token",
"bearer",
"authorization",
"auth",
"auth_token",
"access_token",
"refresh_token",
# Session and cookies
"cookie",
"session",
"session_id",
"sessionid",
"credential",
"credentials",
# Database
"connection_string",
"connectionstring",
"database_url",
"db_url",
"dburl",
"db_password",
"db_pass",
# Cloud provider specific
"aws_secret",
"aws_secret_access_key",
"azure_key",
"gcp_key",
"openai_api_key",
"anthropic_api_key",
}
# Compiled regex for case-insensitive matching
_REDACT_PATTERN = re.compile(
r"(" + "|".join(re.escape(k) for k in REDACT_KEYS) + r")",
re.IGNORECASE
)
REDACTED_VALUE = "[REDACTED]"
def _should_redact(key: str) -> bool:
"""Check if a key should be redacted."""
# Normalize key for comparison
normalized = key.lower().replace("-", "_").replace(" ", "_")
# Check exact match first
if normalized in REDACT_KEYS:
return True
# Check if any redact key is contained in the normalized key
for redact_key in REDACT_KEYS:
if redact_key in normalized:
return True
return False
def redact_dict(data: Dict[str, Any], enabled: bool = True) -> Dict[str, Any]:
"""
Redact sensitive values from a dictionary.
Args:
data: Dictionary to redact
enabled: If False, returns data unchanged
Returns:
New dictionary with sensitive values redacted
"""
if not enabled:
return data
if not data:
return {}
return _redact_value(data)
def _redact_value(value: Any) -> Any:
"""Recursively redact sensitive values."""
if isinstance(value, dict):
return {k: _redact_key_value(k, v) for k, v in value.items()}
elif isinstance(value, list):
return [_redact_value(item) for item in value]
else:
return value
def _redact_key_value(key: str, value: Any) -> Any:
"""Redact a value if its key is sensitive."""
if _should_redact(key):
return REDACTED_VALUE
# Recursively process nested structures
return _redact_value(value)
def redact_string(text: str, enabled: bool = True) -> str:
"""
Redact potential secrets from a string.
This is a best-effort function that looks for common patterns
like "api_key=xxx" or "password: xxx" in strings.
Args:
text: String to redact
enabled: If False, returns text unchanged
Returns:
String with potential secrets redacted
"""
if not enabled or not text:
return text
# Pattern for key=value or key: value
patterns = [
(r'(["\']?)(' + '|'.join(re.escape(k) for k in REDACT_KEYS) + r')(["\']?\s*[:=]\s*["\']?)([^"\'\s,}\]]+)',
r'\1\2\3[REDACTED]'),
]
result = text
for pattern, replacement in patterns:
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
return result
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Detailed profiler to identify the exact source of streaming delay.
Traces every step from agent.start() to HTTP request.
"""
import os
import sys
import time
import functools
os.environ['LOGLEVEL'] = 'DEBUG'
# Microsecond precision timestamps
def ts():
return time.perf_counter()
start_time = ts()
def log(msg):
elapsed = (ts() - start_time) * 1000
print(f"[{elapsed:8.2f}ms] {msg}")
log("Script starting...")
# ============================================================
# PATCH 1: OpenAI client initialization
# ============================================================
import openai
original_openai_init = openai.OpenAI.__init__
@functools.wraps(original_openai_init)
def traced_openai_init(self, *args, **kwargs):
t0 = ts()
result = original_openai_init(self, *args, **kwargs)
log(f"OpenAI.__init__() took {(ts()-t0)*1000:.2f}ms")
return result
openai.OpenAI.__init__ = traced_openai_init
# ============================================================
# PATCH 2: OpenAI completions.create
# ============================================================
original_create = openai.resources.chat.completions.Completions.create
@functools.wraps(original_create)
def traced_create(self, *args, **kwargs):
log(f">>> OpenAI.create() CALLED - stream={kwargs.get('stream', False)}")
t0 = ts()
result = original_create(self, *args, **kwargs)
log(f"<<< OpenAI.create() RETURNED in {(ts()-t0)*1000:.2f}ms")
return result
openai.resources.chat.completions.Completions.create = traced_create
# ============================================================
# PATCH 3: httpx.Client.send (actual HTTP request)
# ============================================================
import httpx
original_send = httpx.Client.send
@functools.wraps(original_send)
def traced_send(self, request, *args, **kwargs):
log(f">>> HTTPX.send() {request.method} {request.url}")
t0 = ts()
result = original_send(self, request, *args, **kwargs)
log(f"<<< HTTPX.send() returned in {(ts()-t0)*1000:.2f}ms - status={result.status_code}")
return result
httpx.Client.send = traced_send
# ============================================================
# PATCH 4: Rich Live display
# ============================================================
from rich.live import Live
original_live_init = Live.__init__
original_live_enter = Live.__enter__
original_live_exit = Live.__exit__
@functools.wraps(original_live_init)
def traced_live_init(self, *args, **kwargs):
log(f">>> Live.__init__() called")
t0 = ts()
result = original_live_init(self, *args, **kwargs)
log(f"<<< Live.__init__() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_live_enter)
def traced_live_enter(self):
log(f">>> Live.__enter__() called")
t0 = ts()
result = original_live_enter(self)
log(f"<<< Live.__enter__() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_live_exit)
def traced_live_exit(self, *args):
log(f">>> Live.__exit__() called")
t0 = ts()
result = original_live_exit(self, *args)
log(f"<<< Live.__exit__() took {(ts()-t0)*1000:.2f}ms")
return result
Live.__init__ = traced_live_init
Live.__enter__ = traced_live_enter
Live.__exit__ = traced_live_exit
log("Patches applied, importing praisonaiagents...")
# ============================================================
# Import and trace Agent
# ============================================================
t0 = ts()
from praisonaiagents import Agent
log(f"Agent import took {(ts()-t0)*1000:.2f}ms")
# ============================================================
# PATCH 5: Agent methods
# ============================================================
from praisonaiagents.agent.agent import Agent as AgentClass
original_chat_completion = AgentClass._chat_completion
original_format_tools = AgentClass._format_tools_for_completion
@functools.wraps(original_chat_completion)
def traced_chat_completion(self, messages, temperature=1.0, tools=None, stream=True,
reasoning_steps=False, task_name=None, task_description=None, task_id=None):
log(f">>> Agent._chat_completion() stream={stream}, _using_custom_llm={self._using_custom_llm}")
t0 = ts()
result = original_chat_completion(self, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id)
log(f"<<< Agent._chat_completion() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_format_tools)
def traced_format_tools(self, tools):
log(f">>> Agent._format_tools_for_completion() tools={tools}")
t0 = ts()
result = original_format_tools(self, tools)
log(f"<<< Agent._format_tools_for_completion() took {(ts()-t0)*1000:.2f}ms")
return result
AgentClass._chat_completion = traced_chat_completion
AgentClass._format_tools_for_completion = traced_format_tools
# ============================================================
# PATCH 6: OpenAIClient methods
# ============================================================
from praisonaiagents.llm.openai_client import OpenAIClient
original_chat_with_tools = OpenAIClient.chat_completion_with_tools
original_process_stream = OpenAIClient.process_stream_response
original_create_completion = OpenAIClient.create_completion
original_format_tools_client = OpenAIClient.format_tools
@functools.wraps(original_chat_with_tools)
def traced_chat_with_tools(self, *args, **kwargs):
log(f">>> OpenAIClient.chat_completion_with_tools() stream={kwargs.get('stream', True)}")
t0 = ts()
result = original_chat_with_tools(self, *args, **kwargs)
log(f"<<< OpenAIClient.chat_completion_with_tools() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_process_stream)
def traced_process_stream(self, *args, **kwargs):
log(f">>> OpenAIClient.process_stream_response()")
t0 = ts()
result = original_process_stream(self, *args, **kwargs)
log(f"<<< OpenAIClient.process_stream_response() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_create_completion)
def traced_create_completion(self, *args, **kwargs):
log(f">>> OpenAIClient.create_completion() stream={kwargs.get('stream', False)}")
t0 = ts()
result = original_create_completion(self, *args, **kwargs)
log(f"<<< OpenAIClient.create_completion() took {(ts()-t0)*1000:.2f}ms")
return result
@functools.wraps(original_format_tools_client)
def traced_format_tools_client(self, tools):
log(f">>> OpenAIClient.format_tools() tools count={len(tools) if tools else 0}")
t0 = ts()
result = original_format_tools_client(self, tools)
log(f"<<< OpenAIClient.format_tools() took {(ts()-t0)*1000:.2f}ms")
return result
OpenAIClient.chat_completion_with_tools = traced_chat_with_tools
OpenAIClient.process_stream_response = traced_process_stream
OpenAIClient.create_completion = traced_create_completion
OpenAIClient.format_tools = traced_format_tools_client
# ============================================================
# PATCH 7: sync_client property (lazy init)
# ============================================================
original_sync_client_getter = OpenAIClient.sync_client.fget
def traced_sync_client_getter(self):
log(f">>> OpenAIClient.sync_client property accessed")
t0 = ts()
result = original_sync_client_getter(self)
log(f"<<< OpenAIClient.sync_client took {(ts()-t0)*1000:.2f}ms")
return result
OpenAIClient.sync_client = property(traced_sync_client_getter)
log("All patches applied. Creating agent...")
# ============================================================
# RUN THE TEST
# ============================================================
t0 = ts()
agent = Agent(
instructions="You are a helpful assistant"
)
log(f"Agent created in {(ts()-t0)*1000:.2f}ms")
log(f"Agent.stream={agent.stream}, Agent.verbose={agent.verbose}")
log("=" * 60)
log("Starting agent.start()...")
log("=" * 60)
t0 = ts()
result = agent.start("Say hello in one word")
log(f"agent.start() completed in {(ts()-t0)*1000:.2f}ms")
log("=" * 60)
log("DONE")
log("=" * 60)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.11.2"
version = "0.11.5"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Micro-benchmark for output mode overhead comparison.
Measures:
1. Import-time cost (cold import)
2. Agent initialization overhead per output mode
3. Hot-path overhead (simulated callback execution)
Usage:
python tests/benchmarks/output_mode_benchmark.py
All stdlib - no external dependencies required.
"""
import subprocess
import sys
import time
from statistics import mean, stdev
def measure_import_time(mode: str, iterations: int = 5) -> dict:
"""Measure cold import time for a specific output mode."""
times = []
# Script that imports and creates an agent with specific output mode
script = f'''
import time
start = time.perf_counter()
import os
os.environ['PRAISONAI_OUTPUT'] = '{mode}'
from praisonaiagents import Agent
agent = Agent(name="BenchAgent", instructions="Test", output="{mode}")
end = time.perf_counter()
print(f"{{(end - start) * 1000:.3f}}")
'''
for _ in range(iterations):
result = subprocess.run(
[sys.executable, '-c', script],
capture_output=True,
text=True,
env={**dict(__import__('os').environ), 'PYTHONDONTWRITEBYTECODE': '1'}
)
if result.returncode == 0:
try:
times.append(float(result.stdout.strip()))
except ValueError:
pass
if not times:
return {"mode": mode, "mean_ms": -1, "stdev_ms": -1, "samples": 0}
return {
"mode": mode,
"mean_ms": mean(times),
"stdev_ms": stdev(times) if len(times) > 1 else 0,
"samples": len(times),
}
def measure_callback_overhead(iterations: int = 1000) -> dict:
"""Measure hot-path overhead of callback execution vs no callbacks."""
results = {}
# Silent mode - no callbacks
silent_times = []
for _ in range(iterations):
start = time.perf_counter()
# Simulate what happens in silent mode: nothing
pass
end = time.perf_counter()
silent_times.append((end - start) * 1_000_000) # microseconds
results["silent"] = {
"mean_us": mean(silent_times),
"stdev_us": stdev(silent_times) if len(silent_times) > 1 else 0,
}
# Actions mode - simulate callback overhead
actions_times = []
# Simulate the work done by actions mode per tool call
def simulate_actions_callback():
import time as t
# Simulate: time.time(), dict creation, string formatting, stderr write
_ = t.time()
event = {
"type": "tool_call",
"tool": "test_tool",
"args": {"arg1": "value1", "arg2": "value2"},
"timestamp": t.time(),
}
formatted = f"[tool_call] {event['tool']}({event['args']})"
# Don't actually write to avoid polluting output
_ = formatted.encode()
for _ in range(iterations):
start = time.perf_counter()
simulate_actions_callback()
end = time.perf_counter()
actions_times.append((end - start) * 1_000_000) # microseconds
results["actions"] = {
"mean_us": mean(actions_times),
"stdev_us": stdev(actions_times) if len(actions_times) > 1 else 0,
}
return results
def measure_agent_init_overhead() -> dict:
"""Measure Agent.__init__ overhead for different output modes."""
import os
# Clear any env var
os.environ.pop('PRAISONAI_OUTPUT', None)
results = {}
iterations = 10
for mode in ["silent", "actions", "verbose"]:
times = []
for _ in range(iterations):
# Import fresh each time to avoid caching effects
from praisonaiagents import Agent
start = time.perf_counter()
agent = Agent(
name="BenchAgent",
instructions="Test agent",
output=mode,
)
end = time.perf_counter()
times.append((end - start) * 1000) # milliseconds
del agent
results[mode] = {
"mean_ms": mean(times),
"stdev_ms": stdev(times) if len(times) > 1 else 0,
}
return results
def main():
print("=" * 60)
print("OUTPUT MODE MICRO-BENCHMARK")
print("=" * 60)
print()
# 1. Import time comparison
print("1. IMPORT + INIT TIME (cold start, subprocess)")
print("-" * 40)
modes = ["silent", "actions", "verbose"]
import_results = []
for mode in modes:
print(f" Measuring {mode}...", end=" ", flush=True)
result = measure_import_time(mode, iterations=3)
import_results.append(result)
print(f"{result['mean_ms']:.1f}ms (±{result['stdev_ms']:.1f})")
print()
# 2. Agent init overhead (warm)
print("2. AGENT INIT OVERHEAD (warm, same process)")
print("-" * 40)
init_results = measure_agent_init_overhead()
for mode, data in init_results.items():
print(f" {mode:12s}: {data['mean_ms']:.2f}ms (±{data['stdev_ms']:.2f})")
print()
# 3. Hot-path callback overhead
print("3. HOT-PATH CALLBACK OVERHEAD (per call)")
print("-" * 40)
callback_results = measure_callback_overhead(iterations=10000)
for mode, data in callback_results.items():
print(f" {mode:12s}: {data['mean_us']:.3f}µs (±{data['stdev_us']:.3f})")
print()
# Summary
print("=" * 60)
print("SUMMARY")
print("=" * 60)
silent_init = init_results.get("silent", {}).get("mean_ms", 0)
actions_init = init_results.get("actions", {}).get("mean_ms", 0)
silent_callback = callback_results.get("silent", {}).get("mean_us", 0)
actions_callback = callback_results.get("actions", {}).get("mean_us", 0)
print(f" Init overhead (actions vs silent): +{actions_init - silent_init:.2f}ms")
print(f" Per-call overhead (actions vs silent): +{actions_callback - silent_callback:.3f}µs")
print()
print(" DEFAULT MODE: silent (zero overhead)")
print(" OPT-IN: output='actions' for observability")
print()
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Tests for the trace module."""
@@ -0,0 +1,450 @@
"""
Tests for the trace protocol module.
TDD tests for ActionEvent, TraceSink protocol, and redaction.
"""
import time
import json
class TestActionEvent:
"""Tests for ActionEvent dataclass."""
def test_action_event_creation_minimal(self):
"""Test creating ActionEvent with minimal fields."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="agent_start",
timestamp=1736665496.123,
)
assert event.event_type == "agent_start"
assert event.timestamp == 1736665496.123
assert event.schema_version == "1.0"
assert event.agent_id is None
assert event.agent_name is None
assert event.tool_name is None
def test_action_event_creation_full(self):
"""Test creating ActionEvent with all fields."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="tool_end",
timestamp=1736665497.456,
agent_id="agent-123",
agent_name="researcher",
tool_name="web_search",
tool_args={"query": "AI trends"},
tool_result_summary="Found 10 results",
duration_ms=800.5,
status="ok",
error_message=None,
metadata={"provider": "duckduckgo"},
)
assert event.event_type == "tool_end"
assert event.agent_name == "researcher"
assert event.tool_name == "web_search"
assert event.tool_args == {"query": "AI trends"}
assert event.duration_ms == 800.5
assert event.status == "ok"
assert event.metadata == {"provider": "duckduckgo"}
def test_action_event_to_dict(self):
"""Test ActionEvent.to_dict() method."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="agent_start",
timestamp=1736665496.123,
agent_name="writer",
)
d = event.to_dict()
assert d["schema_version"] == "1.0"
assert d["event_type"] == "agent_start"
assert d["timestamp"] == 1736665496.123
assert d["agent_name"] == "writer"
# None values should be excluded
assert "tool_name" not in d or d["tool_name"] is None
def test_action_event_to_json(self):
"""Test ActionEvent.to_json() method."""
from praisonaiagents.trace.protocol import ActionEvent
event = ActionEvent(
event_type="tool_start",
timestamp=1736665497.0,
tool_name="read_file",
tool_args={"path": "/tmp/test.txt"},
)
json_str = event.to_json()
parsed = json.loads(json_str)
assert parsed["event_type"] == "tool_start"
assert parsed["tool_name"] == "read_file"
assert parsed["tool_args"] == {"path": "/tmp/test.txt"}
def test_action_event_types(self):
"""Test all valid event types."""
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType
valid_types = [
ActionEventType.AGENT_START,
ActionEventType.AGENT_END,
ActionEventType.TOOL_START,
ActionEventType.TOOL_END,
ActionEventType.ERROR,
ActionEventType.OUTPUT,
]
for event_type in valid_types:
event = ActionEvent(
event_type=event_type.value,
timestamp=time.time(),
)
assert event.event_type == event_type.value
class TestRedaction:
"""Tests for redaction functionality."""
def test_redact_api_key(self):
"""Test redacting api_key field."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": "sk-1234567890abcdef", "query": "hello"}
redacted = redact_dict(data)
assert redacted["api_key"] == "[REDACTED]"
assert redacted["query"] == "hello"
def test_redact_multiple_sensitive_keys(self):
"""Test redacting multiple sensitive keys."""
from praisonaiagents.trace.redact import redact_dict
data = {
"api_key": "sk-secret",
"password": "mypassword123",
"token": "bearer-token",
"authorization": "Bearer xyz",
"secret": "top-secret",
"normal_field": "visible",
}
redacted = redact_dict(data)
assert redacted["api_key"] == "[REDACTED]"
assert redacted["password"] == "[REDACTED]"
assert redacted["token"] == "[REDACTED]"
assert redacted["authorization"] == "[REDACTED]"
assert redacted["secret"] == "[REDACTED]"
assert redacted["normal_field"] == "visible"
def test_redact_nested_dict(self):
"""Test redacting nested dictionaries."""
from praisonaiagents.trace.redact import redact_dict
data = {
"config": {
"api_key": "sk-nested",
"endpoint": "https://api.example.com",
},
"name": "test",
}
redacted = redact_dict(data)
assert redacted["config"]["api_key"] == "[REDACTED]"
assert redacted["config"]["endpoint"] == "https://api.example.com"
assert redacted["name"] == "test"
def test_redact_list_of_dicts(self):
"""Test redacting list containing dictionaries."""
from praisonaiagents.trace.redact import redact_dict
data = {
"items": [
{"name": "item1", "secret": "hidden1"},
{"name": "item2", "secret": "hidden2"},
]
}
redacted = redact_dict(data)
assert redacted["items"][0]["name"] == "item1"
assert redacted["items"][0]["secret"] == "[REDACTED]"
assert redacted["items"][1]["secret"] == "[REDACTED]"
def test_redact_case_insensitive(self):
"""Test that redaction is case-insensitive."""
from praisonaiagents.trace.redact import redact_dict
data = {
"API_KEY": "key1",
"Api_Key": "key2",
"apiKey": "key3",
"APIKEY": "key4",
}
redacted = redact_dict(data)
assert redacted["API_KEY"] == "[REDACTED]"
assert redacted["Api_Key"] == "[REDACTED]"
assert redacted["apiKey"] == "[REDACTED]"
assert redacted["APIKEY"] == "[REDACTED]"
def test_redact_connection_strings(self):
"""Test redacting database connection strings."""
from praisonaiagents.trace.redact import redact_dict
data = {
"connection_string": "postgresql://user:pass@host/db",
"database_url": "mysql://root:secret@localhost/mydb",
"db_url": "redis://default:password@redis:6379",
}
redacted = redact_dict(data)
assert redacted["connection_string"] == "[REDACTED]"
assert redacted["database_url"] == "[REDACTED]"
assert redacted["db_url"] == "[REDACTED]"
def test_redact_preserves_original(self):
"""Test that redaction doesn't modify original dict."""
from praisonaiagents.trace.redact import redact_dict
original = {"api_key": "secret", "name": "test"}
redacted = redact_dict(original)
assert original["api_key"] == "secret"
assert redacted["api_key"] == "[REDACTED]"
def test_redact_empty_dict(self):
"""Test redacting empty dictionary."""
from praisonaiagents.trace.redact import redact_dict
assert redact_dict({}) == {}
def test_redact_none_values(self):
"""Test handling None values."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": None, "name": "test"}
redacted = redact_dict(data)
# None values for sensitive keys should still be redacted
assert redacted["api_key"] == "[REDACTED]"
assert redacted["name"] == "test"
def test_redact_disabled(self):
"""Test disabling redaction."""
from praisonaiagents.trace.redact import redact_dict
data = {"api_key": "visible", "name": "test"}
result = redact_dict(data, enabled=False)
assert result["api_key"] == "visible"
assert result["name"] == "test"
class TestTraceSink:
"""Tests for TraceSink protocol."""
def test_noop_sink(self):
"""Test NoOpSink does nothing."""
from praisonaiagents.trace.protocol import NoOpSink, ActionEvent
sink = NoOpSink()
event = ActionEvent(event_type="agent_start", timestamp=time.time())
# Should not raise
sink.emit(event)
sink.flush()
sink.close()
def test_list_sink(self):
"""Test ListSink collects events."""
from praisonaiagents.trace.protocol import ListSink, ActionEvent
sink = ListSink()
event1 = ActionEvent(event_type="agent_start", timestamp=1.0)
event2 = ActionEvent(event_type="tool_start", timestamp=2.0)
sink.emit(event1)
sink.emit(event2)
events = sink.get_events()
assert len(events) == 2
assert events[0].event_type == "agent_start"
assert events[1].event_type == "tool_start"
def test_list_sink_clear(self):
"""Test ListSink.clear() method."""
from praisonaiagents.trace.protocol import ListSink, ActionEvent
sink = ListSink()
sink.emit(ActionEvent(event_type="test", timestamp=1.0))
assert len(sink.get_events()) == 1
sink.clear()
assert len(sink.get_events()) == 0
class TestTraceEmitter:
"""Tests for TraceEmitter class."""
def test_emitter_with_noop_sink(self):
"""Test TraceEmitter with NoOpSink."""
from praisonaiagents.trace.protocol import TraceEmitter, NoOpSink
emitter = TraceEmitter(sink=NoOpSink())
# Should not raise
emitter.agent_start("test-agent", agent_id="123")
emitter.tool_start("web_search", {"query": "test"})
emitter.tool_end("web_search", duration_ms=100, status="ok")
emitter.agent_end("test-agent", duration_ms=500)
def test_emitter_with_list_sink(self):
"""Test TraceEmitter collects events."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.agent_start("researcher")
emitter.tool_start("search", {"q": "AI"})
emitter.tool_end("search", duration_ms=100, status="ok")
emitter.agent_end("researcher", duration_ms=200)
events = sink.get_events()
assert len(events) == 4
assert events[0].event_type == "agent_start"
assert events[1].event_type == "tool_start"
assert events[2].event_type == "tool_end"
assert events[3].event_type == "agent_end"
def test_emitter_redacts_by_default(self):
"""Test TraceEmitter redacts sensitive data by default."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink, redact=True)
emitter.tool_start("api_call", {"api_key": "sk-secret", "query": "test"})
events = sink.get_events()
assert events[0].tool_args["api_key"] == "[REDACTED]"
assert events[0].tool_args["query"] == "test"
def test_emitter_disabled(self):
"""Test TraceEmitter when disabled."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink, enabled=False)
emitter.agent_start("test")
emitter.tool_start("tool", {})
# No events should be emitted
assert len(sink.get_events()) == 0
def test_emitter_error_event(self):
"""Test TraceEmitter error event."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.error("Something went wrong", tool_name="failing_tool")
events = sink.get_events()
assert len(events) == 1
assert events[0].event_type == "error"
assert events[0].error_message == "Something went wrong"
assert events[0].tool_name == "failing_tool"
def test_emitter_output_event(self):
"""Test TraceEmitter output event for final agent output."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
sink = ListSink()
emitter = TraceEmitter(sink=sink)
emitter.output("The analysis shows three major trends...")
events = sink.get_events()
assert len(events) == 1
assert events[0].event_type == "output"
assert "three major trends" in events[0].tool_result_summary
class TestActionTraceConfig:
"""Tests for ActionTraceConfig."""
def test_config_defaults(self):
"""Test ActionTraceConfig default values."""
from praisonaiagents.trace.protocol import ActionTraceConfig
config = ActionTraceConfig()
assert config.enabled is True
assert config.redact is True
assert config.compact is False
assert config.sink_type == "noop"
assert config.file_path is None
def test_config_custom(self):
"""Test ActionTraceConfig custom values."""
from praisonaiagents.trace.protocol import ActionTraceConfig
config = ActionTraceConfig(
enabled=True,
redact=False,
compact=True,
sink_type="jsonl",
file_path="/tmp/trace.jsonl",
)
assert config.redact is False
assert config.compact is True
assert config.sink_type == "jsonl"
assert config.file_path == "/tmp/trace.jsonl"
class TestPerformance:
"""Performance tests for trace module."""
def test_noop_sink_overhead(self):
"""Test that NoOpSink has minimal overhead."""
from praisonaiagents.trace.protocol import TraceEmitter, NoOpSink, ActionEvent
emitter = TraceEmitter(sink=NoOpSink())
start = time.perf_counter()
for _ in range(10000):
emitter.tool_start("test", {"arg": "value"})
emitter.tool_end("test", duration_ms=1, status="ok")
elapsed = time.perf_counter() - start
# Should complete 10000 iterations in under 100ms
assert elapsed < 0.1, f"NoOpSink too slow: {elapsed:.3f}s for 10000 iterations"
def test_disabled_emitter_overhead(self):
"""Test that disabled emitter has near-zero overhead."""
from praisonaiagents.trace.protocol import TraceEmitter, ListSink
emitter = TraceEmitter(sink=ListSink(), enabled=False)
start = time.perf_counter()
for _ in range(10000):
emitter.tool_start("test", {"arg": "value"})
elapsed = time.perf_counter() - start
# Should complete 10000 iterations in under 500ms (very conservative)
assert elapsed < 0.5, f"Disabled emitter too slow: {elapsed:.3f}s"
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.2"
version = "0.11.5"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.3.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.3.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.9.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+11 -4
View File
@@ -141,11 +141,18 @@ def main_callback(
)
set_output_controller(state.output_controller)
# If no command provided, show help or handle legacy behavior
# If no command provided, start interactive mode
if ctx.invoked_subcommand is None:
# Check if there are remaining args (legacy behavior)
# This is handled by the legacy adapter in main.py
pass
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
tui_config = AsyncTUIConfig(
model="gpt-4o-mini",
show_logo=True,
show_status_bar=state.output_format != OutputFormat.json,
)
tui = AsyncTUI(config=tui_config)
tui.run()
def get_output_controller() -> OutputController:
+94
View File
@@ -0,0 +1,94 @@
"""
PraisonAI Branding - Single Source of Truth.
This module provides unified branding assets (logo, version, product name)
for all interactive UIs. All frontends should import from here.
Usage:
from praisonai.cli.branding import get_logo, get_version, PRODUCT_NAME
"""
# Product name
PRODUCT_NAME = "Praison AI"
# ASCII Art Logos - responsive to terminal width
LOGO_LARGE = r"""
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██████╔╝██████╔╝███████║██║███████╗██║ ██║██╔██╗ ██║ ███████║██║
██╔═══╝ ██╔══██╗██╔══██║██║╚════██║██║ ██║██║╚██╗██║ ██╔══██║██║
██║ ██║ ██║██║ ██║██║███████║╚██████╔╝██║ ╚████║ ██║ ██║██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
"""
LOGO_MEDIUM = r"""
╔═╗┬─┐┌─┐┬┌─┐┌─┐┌┐┌ ╔═╗╦
╠═╝├┬┘├─┤│└─┐│ ││││ ╠═╣║
╩ ┴└─┴ ┴┴└─┘└─┘┘└┘ ╩ ╩╩
"""
LOGO_SMALL = "▶ Praison AI"
LOGO_MINIMAL = "Praison AI"
def get_version() -> str:
"""Get PraisonAI version string."""
try:
from praisonai import __version__
return __version__
except Exception:
return "1.0.0"
def get_logo(width: int = 80) -> str:
"""
Get appropriate logo based on terminal width.
Args:
width: Terminal width in characters
Returns:
ASCII art logo string appropriate for the width
"""
if width >= 75:
return LOGO_LARGE
elif width >= 40:
return LOGO_MEDIUM
else:
return LOGO_SMALL
def get_banner(width: int = 80, show_version: bool = True, model: str = None) -> str:
"""
Get full banner with logo, version, and optional model info.
Args:
width: Terminal width
show_version: Whether to include version
model: Optional model name to display
Returns:
Complete banner string
"""
lines = []
# Logo
logo = get_logo(width)
lines.append(logo.strip())
lines.append("")
# Version and model
version_line = f" v{get_version()}"
if model:
version_line += f" · Model: {model}"
lines.append(version_line)
lines.append("")
return "\n".join(lines)
def get_welcome_tips() -> str:
"""Get welcome tips for new sessions."""
return """ Type your message and press Enter. Use /help for commands.
Use PageUp/PageDown or Ctrl+Up/Down to scroll."""
+35 -68
View File
@@ -67,6 +67,7 @@ def chat_main(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
no_acp: bool = typer.Option(False, "--no-acp", help="Disable ACP tools"),
no_lsp: bool = typer.Option(False, "--no-lsp", help="Disable LSP tools"),
safe_mode: bool = typer.Option(False, "--safe", help="Safe mode: require approval for file writes and commands"),
autonomy: bool = typer.Option(True, "--autonomy/--no-autonomy", help="Enable agent autonomy for complex tasks"),
# NEW: Agent-like consolidated params for ALL GREEN consistency
knowledge: Optional[str] = typer.Option(
@@ -107,8 +108,8 @@ def chat_main(
flag_value="true",
),
output: Optional[str] = typer.Option(
None, "--output",
help="Output preset. Use --output=verbose, --output=minimal, --output=silent",
None, "--output", "-o",
help="Output mode (default: actions). Options: actions, plain, verbose, json, silent",
),
execution: Optional[str] = typer.Option(
None, "--execution",
@@ -126,6 +127,7 @@ def chat_main(
),
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
debug: bool = typer.Option(False, "--debug", help="Enable debug logging to ~/.praisonai/async_tui_debug.log"),
# UI backend selection
ui_backend: str = typer.Option("auto", "--ui-backend", help="UI backend: auto, plain, rich, mg (middle-ground)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON (forces plain backend)"),
@@ -149,25 +151,12 @@ def chat_main(
praisonai chat "Summarize this" --file README.md
praisonai chat "What is 2+2?" --profile
"""
import asyncio
import os
# Set workspace if provided
if workspace:
os.environ["PRAISONAI_WORKSPACE"] = workspace
# Configure UI backend
from praisonai.cli.ui import select_backend, UIConfig
ui_config = UIConfig(
ui_backend=ui_backend,
json_output=json_output,
no_color=no_color,
theme=theme,
compact=compact,
verbose=verbose,
)
backend = select_backend(ui_config)
# Handle profiling for single prompt mode
if prompt and (profile or profile_deep):
_run_profiled_chat(
@@ -184,60 +173,38 @@ def chat_main(
typer.echo(" Use: praisonai chat \"your prompt\" --profile", err=True)
# Parse memory flag: --no-memory takes precedence, then --memory value
memory_value = _parse_memory_flag(memory, no_memory)
# TODO: Pass memory_value to TUI when memory support is added
_parse_memory_flag(memory, no_memory)
# Try InteractiveCore first (preferred terminal-native implementation)
try:
from praisonai.cli.interactive import InteractiveCore, InteractiveConfig
from praisonai.cli.interactive.frontends import RichFrontend
config = InteractiveConfig(
model=model,
session_id=session_id,
continue_session=continue_session,
workspace=workspace or None,
verbose=verbose,
memory=memory_value,
files=list(file) if file else [],
autonomy=autonomy,
)
core = InteractiveCore(config=config)
if prompt:
# Single prompt mode - use backend for output
from praisonai.cli.ui.events import UIEventType
async def run_prompt():
if continue_session:
core.continue_session()
backend.emit(UIEventType.MESSAGE_START, {})
response = await core.prompt(prompt)
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': response})
backend.emit(UIEventType.MESSAGE_END, {})
asyncio.run(run_prompt())
else:
# Interactive REPL mode - pass ui_config to frontend
frontend = RichFrontend(core=core, config=config, ui_config=ui_config)
asyncio.run(frontend.run())
except ImportError:
# Fallback to legacy terminal-native interactive mode
_run_legacy_terminal_chat(
prompt=prompt,
model=model,
verbose=verbose,
memory=memory,
tools=tools,
user_id=user_id,
session_id=session_id,
continue_session=continue_session,
workspace=workspace,
no_acp=no_acp,
no_lsp=no_lsp,
)
# Set approval mode based on --safe flag
import os
if safe_mode:
os.environ["PRAISON_APPROVAL_MODE"] = "prompt"
else:
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# Use the async TUI (non-blocking, scrollable output)
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
tui_config = AsyncTUIConfig(
model=model or "gpt-4o-mini",
show_logo=not compact,
show_status_bar=not compact,
session_id=session_id,
workspace=workspace,
debug=debug,
)
tui = AsyncTUI(config=tui_config)
if prompt:
# Single prompt mode - direct response, no streaming
response = tui.run_single(prompt)
if response:
print(response)
else:
# Interactive split-pane TUI mode
tui.run()
def _run_profiled_chat(
@@ -23,6 +23,7 @@ def code_main(
file: Optional[List[str]] = typer.Option(None, "--file", "-f", help="Attach file(s) to context"),
no_acp: bool = typer.Option(False, "--no-acp", help="Disable ACP tools (file operations)"),
no_lsp: bool = typer.Option(False, "--no-lsp", help="Disable LSP tools (code intelligence)"),
safe_mode: bool = typer.Option(False, "--safe", help="Safe mode: require approval for file writes and commands"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
continue_session: bool = typer.Option(False, "--continue", "-c", help="Continue last session"),
autonomy: bool = typer.Option(True, "--autonomy/--no-autonomy", help="Enable agent autonomy for complex tasks"),
@@ -54,6 +55,12 @@ def code_main(
# Enable code-specific environment
os.environ["PRAISONAI_CODE_MODE"] = "true"
# Set approval mode based on --safe flag
if safe_mode:
os.environ["PRAISON_APPROVAL_MODE"] = "prompt"
else:
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
# Handle profiling for single prompt mode
if prompt and (profile or profile_deep):
_run_profiled_code(
+30 -1
View File
@@ -29,6 +29,7 @@ def run_main(
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
output_mode: Optional[str] = typer.Option(None, "--output", "-o", help="Output mode: silent (default), actions, verbose, json, stream"),
):
"""
Run agents from a file or prompt.
@@ -108,6 +109,7 @@ def run_main(
memory=memory,
tools=tools,
max_tokens=max_tokens,
output_mode=output_mode,
)
else:
# Run as prompt
@@ -120,6 +122,7 @@ def run_main(
memory=memory,
tools=tools,
max_tokens=max_tokens,
output_mode=output_mode,
)
@@ -134,6 +137,7 @@ def _run_from_file(
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
):
"""Run agents from a YAML file."""
output = get_output_controller()
@@ -178,12 +182,37 @@ def _run_prompt(
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
output_mode: Optional[str] = None,
):
"""Run a direct prompt."""
output = get_output_controller()
try:
# Use existing handle_direct_prompt
# If output_mode is "actions", use direct Agent with actions preset
if output_mode == "actions":
from praisonaiagents import Agent
agent_config = {
"name": "RunAgent",
"role": "Assistant",
"goal": "Complete the task",
"output": "actions", # Use actions preset
}
if model:
agent_config["llm"] = model
agent = Agent(**agent_config)
result = agent.start(prompt)
output.emit_result(
message="Prompt completed",
data={"result": str(result) if result else None}
)
# Don't print result again - actions mode already shows output
return
# Use existing handle_direct_prompt for other modes
from praisonai.cli.main import PraisonAI
praison = PraisonAI()
@@ -160,6 +160,8 @@ class ActionOrchestrator:
async def _create_fallback_plan(self, prompt: str, plan_id: str) -> ActionResult:
"""Create a fallback plan when ACP is not available for planning."""
import re # Import at function level for all pattern matching
# This is a simplified fallback - in production, this would use LLM
steps = []
@@ -168,7 +170,6 @@ class ActionOrchestrator:
if "create" in prompt_lower or "new file" in prompt_lower:
# Extract file path if mentioned
import re
file_match = re.search(r'(?:create|new)\s+(?:a\s+)?(?:file\s+)?(?:called\s+)?([^\s]+\.\w+)', prompt_lower)
target = file_match.group(1) if file_match else "new_file.py"
@@ -188,7 +189,8 @@ class ActionOrchestrator:
))
elif "edit" in prompt_lower or "modify" in prompt_lower or "change" in prompt_lower:
file_match = re.search(r'(?:edit|modify|change)\s+([^\s]+\.\w+)', prompt_lower)
# Match patterns like "edit file X", "edit X", "modify X"
file_match = re.search(r'(?:edit|modify|change)\s+(?:file\s+)?([^\s]+(?:\.\w+)?)', prompt_lower)
target = file_match.group(1) if file_match else ""
if target:
@@ -197,11 +199,13 @@ class ActionOrchestrator:
action_type=ActionType.FILE_EDIT,
description=f"Edit file: {target}",
target=target,
params={"changes": prompt}
params={"changes": prompt},
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "delete" in prompt_lower or "remove" in prompt_lower:
file_match = re.search(r'(?:delete|remove)\s+([^\s]+\.\w+)', prompt_lower)
# Match patterns like "delete file X", "delete X", "remove X"
file_match = re.search(r'(?:delete|remove)\s+(?:file\s+)?([^\s]+(?:\.\w+)?)', prompt_lower)
target = file_match.group(1) if file_match else ""
if target:
@@ -209,7 +213,8 @@ class ActionOrchestrator:
id=self._generate_step_id(plan_id, 0),
action_type=ActionType.FILE_DELETE,
description=f"Delete file: {target}",
target=target
target=target,
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "rename" in prompt_lower:
@@ -218,19 +223,22 @@ class ActionOrchestrator:
action_type=ActionType.REFACTOR,
description="Rename operation",
target="",
params={"prompt": prompt}
params={"prompt": prompt},
status=ActionStatus.APPROVED # Auto-approve for fallback
))
elif "run" in prompt_lower or "execute" in prompt_lower:
cmd_match = re.search(r'(?:run|execute)\s+[`"\']?([^`"\']+)[`"\']?', prompt_lower)
command = cmd_match.group(1) if cmd_match else ""
elif "run" in prompt_lower or "execute" in prompt_lower or "command" in prompt_lower:
# Match patterns like "run command: X", "execute X", "run X"
cmd_match = re.search(r'(?:run|execute)\s+(?:command[:\s]+)?[`"\']?(.+?)[`"\']?$', prompt_lower)
command = cmd_match.group(1).strip() if cmd_match else ""
if command:
steps.append(ActionStep(
id=self._generate_step_id(plan_id, 0),
action_type=ActionType.SHELL_COMMAND,
description=f"Execute: {command}",
target=command
target=command,
status=ActionStatus.APPROVED # Auto-approve for fallback
))
if not steps:
@@ -596,20 +596,34 @@ class CodeIntelligenceRouter:
def _extract_symbol_from_query(self, query: str) -> Optional[str]:
"""Extract symbol name from query."""
# Common patterns for symbol extraction
# Common patterns for symbol extraction (order matters - more specific first)
patterns = [
r'(?:definition\s+of|where\s+is|find|show)\s+["\']?(\w+)["\']?',
# "find all references to X" - capture X after "to"
r'references?\s+to\s+["\']?(\w+)["\']?',
# "definition of X" - capture X after "of"
r'definition\s+of\s+["\']?(\w+)["\']?',
# "where is X defined/used"
r'where\s+is\s+["\']?(\w+)["\']?\s+(?:defined|used)',
# Quoted or backticked symbols
r'["\'](\w+)["\']',
r'`(\w+)`',
# "X is defined/used"
r'(\w+)\s+(?:is\s+)?(?:defined|used)',
# Generic "find/show X" - but filter common words
r'(?:find|show)\s+["\']?(\w+)["\']?',
]
# Words to filter out
filter_words = {'the', 'a', 'an', 'is', 'are', 'in', 'of', 'to', 'for',
'all', 'any', 'some', 'this', 'that', 'where', 'what',
'definition', 'references', 'reference', 'symbol', 'symbols'}
for pattern in patterns:
match = re.search(pattern, query, re.IGNORECASE)
if match:
symbol = match.group(1)
# Filter out common words
if symbol.lower() not in ['the', 'a', 'an', 'is', 'are', 'in', 'of', 'to', 'for']:
if symbol.lower() not in filter_words:
return symbol
return None
@@ -40,7 +40,7 @@ class RuntimeConfig:
workspace: str = "."
lsp_enabled: bool = True
acp_enabled: bool = True
approval_mode: str = "manual" # manual, auto, scoped
approval_mode: str = "auto" # auto (full privileges), manual, scoped
trace_enabled: bool = False
trace_file: Optional[str] = None
json_output: bool = False
@@ -146,15 +146,21 @@ class InteractiveRuntime:
def read_only(self) -> bool:
"""Check if runtime is in read-only mode.
Note: When PRAISON_APPROVAL_MODE=auto, we allow writes even without ACP
to support non-interactive automation and testing scenarios.
Note: When approval_mode=auto (via config or env), we allow writes even
without ACP to support non-interactive automation and testing scenarios.
"""
# Auto-approval mode bypasses read-only for automation
import os as _os
approval_mode = _os.environ.get("PRAISON_APPROVAL_MODE", "").lower()
if approval_mode == "auto":
# Check config approval_mode first (takes precedence)
if self.config.approval_mode == "auto":
return False
return self._read_only or not self.acp_ready
# Then check environment variable as fallback
import os as _os
env_approval_mode = _os.environ.get("PRAISON_APPROVAL_MODE", "").lower()
if env_approval_mode == "auto":
return False
# Only enforce read-only if explicitly set or ACP not ready
return self._read_only
def get_status(self) -> Dict[str, Any]:
"""Get runtime status."""
@@ -72,7 +72,7 @@ class ToolConfig:
enable_acp: bool = True
enable_lsp: bool = True
enable_basic: bool = True
approval_mode: str = "auto" # auto, manual, scoped
approval_mode: str = "auto" # auto (full privileges), manual, scoped
lsp_enabled: bool = True
acp_enabled: bool = True
File diff suppressed because it is too large. Load diff
@@ -50,8 +50,9 @@ class InteractiveConfig:
autonomy: bool = True # Enable autonomy by default for complex tasks
autonomy_config: Optional[dict] = None # Custom autonomy config
# Approval mode
approval_mode: Union[str, ApprovalMode] = "prompt"
# Approval mode - default to "auto" for full privileges in interactive mode
# Use "prompt" for safer mode that asks before each action
approval_mode: Union[str, ApprovalMode] = "auto"
# File attachments
files: List[str] = field(default_factory=list)
@@ -24,6 +24,9 @@ class InteractiveEventType(Enum):
TOOL_START = "tool.start"
TOOL_END = "tool.end"
# Stage transitions (PLAN → EXPLORE → BUILD → REVIEW)
STAGE_CHANGE = "stage.change"
# Approval/permission flow
APPROVAL_ASKED = "approval.asked"
APPROVAL_ANSWERED = "approval.answered"
@@ -0,0 +1,502 @@
"""
PraisonIO - Interactive CLI I/O handler inspired by Aider.
Key design principles:
1. prompt_toolkit used SYNCHRONOUSLY (never inside asyncio.run)
2. Rich for output rendering
3. MarkdownStream for flicker-free streaming
4. Clean separation of input/output concerns
"""
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set
# Lazy imports for performance
_PROMPT_TOOLKIT_AVAILABLE = None
_RICH_AVAILABLE = None
def _check_prompt_toolkit():
global _PROMPT_TOOLKIT_AVAILABLE
if _PROMPT_TOOLKIT_AVAILABLE is None:
try:
import prompt_toolkit # noqa: F401
_PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
_PROMPT_TOOLKIT_AVAILABLE = False
return _PROMPT_TOOLKIT_AVAILABLE
def _check_rich():
global _RICH_AVAILABLE
if _RICH_AVAILABLE is None:
try:
import rich # noqa: F401
_RICH_AVAILABLE = True
except ImportError:
_RICH_AVAILABLE = False
return _RICH_AVAILABLE
@dataclass
class IOConfig:
"""Configuration for PraisonIO."""
# Appearance
pretty: bool = True
user_input_color: str = "#00cc00"
tool_output_color: str = "#0088ff"
tool_error_color: str = "#ff0000"
tool_warning_color: str = "#ffaa00"
assistant_color: str = "#00aaff"
# Input settings
multiline_mode: bool = False
vi_mode: bool = False
# History
input_history_file: Optional[str] = None
# Completion
enable_completions: bool = True
# Streaming
stream_delay: float = 0.05 # 20fps default
class PraisonCompleter:
"""Auto-completer for commands, files, and symbols."""
def __init__(self, commands: Optional[Dict[str, str]] = None):
self.commands = commands or {}
self.files: Set[str] = set()
self.symbols: Set[str] = set()
def add_files(self, files: List[str]) -> None:
"""Add files to completion."""
self.files.update(files)
def add_symbols(self, symbols: List[str]) -> None:
"""Add symbols to completion."""
self.symbols.update(symbols)
def get_completions(self, text: str, cursor_pos: int) -> List[tuple]:
"""Get completions for current input."""
before_cursor = text[:cursor_pos]
words = before_cursor.split()
current_word = words[-1] if words else ""
completions = []
# Slash commands
if current_word.startswith("/"):
cmd_part = current_word[1:].lower()
for cmd, desc in self.commands.items():
if cmd.lower().startswith(cmd_part):
completions.append((f"/{cmd}", desc))
# @ file mentions
elif current_word.startswith("@"):
file_part = current_word[1:].lower()
for f in self.files:
if file_part in f.lower():
completions.append((f"@{f}", "file"))
# General word completion
elif current_word:
word_lower = current_word.lower()
for symbol in self.symbols:
if symbol.lower().startswith(word_lower):
completions.append((symbol, "symbol"))
return completions[:20]
def create_prompt_toolkit_completer(self):
"""Create prompt_toolkit Completer."""
if not _check_prompt_toolkit():
return None
from prompt_toolkit.completion import Completer, Completion
outer = self
class PTCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text
cursor_pos = document.cursor_position
for completion, meta in outer.get_completions(text, cursor_pos):
words = text[:cursor_pos].split()
current_word = words[-1] if words else ""
start_pos = -len(current_word)
yield Completion(completion, start_position=start_pos, display_meta=meta)
return PTCompleter()
class MarkdownStream:
"""Flicker-free streaming markdown renderer.
Uses Rich Live with a sliding window approach:
- Stable lines print above the live area
- Unstable lines (last N) render in the live window
"""
def __init__(self, live_window: int = 6):
self.live_window = live_window
self.printed: List[str] = []
self.live = None
self._live_started = False
self.min_delay = 1.0 / 20 # 20fps
self.when = 0
def _render_to_lines(self, text: str) -> List[str]:
"""Render markdown to lines."""
if not _check_rich():
return text.splitlines(keepends=True)
import io
from rich.console import Console
from rich.markdown import Markdown
string_io = io.StringIO()
console = Console(file=string_io, force_terminal=True, width=100)
console.print(Markdown(text))
return string_io.getvalue().splitlines(keepends=True)
def update(self, text: str, final: bool = False) -> None:
"""Update the streaming display."""
if not _check_rich():
# Fallback: just print
print(text, end="" if not final else "\n", flush=True)
return
from rich.live import Live
from rich.text import Text
# Start live on first update
if not self._live_started:
self.live = Live(Text(""), refresh_per_second=20)
self.live.start()
self._live_started = True
# Throttle updates
now = time.time()
if not final and now - self.when < self.min_delay:
return
self.when = now
lines = self._render_to_lines(text)
num_lines = len(lines)
# Calculate stable lines
if not final:
num_lines -= self.live_window
if final or num_lines > 0:
num_printed = len(self.printed)
show_count = num_lines - num_printed
if show_count > 0:
show = lines[num_printed:num_lines]
show_text = "".join(show)
self.live.console.print(Text.from_ansi(show_text), end="")
self.printed = lines[:num_lines]
if final:
self.live.update(Text(""))
self.live.stop()
self.live = None
return
# Update live window
rest = lines[num_lines:]
rest_text = "".join(rest)
self.live.update(Text.from_ansi(rest_text))
def __del__(self):
if self.live:
try:
self.live.stop()
except Exception:
pass
class PraisonIO:
"""
Main I/O handler for PraisonAI interactive CLI.
Inspired by Aider's io.py - handles all terminal I/O with:
- Rich formatting for output
- prompt_toolkit for input (with completions, history)
- Streaming markdown rendering
"""
def __init__(self, config: Optional[IOConfig] = None):
self.config = config or IOConfig()
self.completer = PraisonCompleter()
self._console = None
self._prompt_session = None
self._history_file = None
# Setup history file
if self.config.input_history_file:
self._history_file = Path(self.config.input_history_file)
else:
self._history_file = Path.home() / ".praison" / "input_history"
self._history_file.parent.mkdir(parents=True, exist_ok=True)
@property
def console(self):
"""Lazy-load Rich console."""
if self._console is None and _check_rich():
from rich.console import Console
self._console = Console()
return self._console
def _create_prompt_session(self):
"""Create prompt_toolkit session."""
if not _check_prompt_toolkit():
return None
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
# Ctrl+C to cancel current input
@kb.add("c-c")
def _(event):
event.current_buffer.reset()
# Alt+Enter for newline in multiline mode
@kb.add("escape", "enter")
def _(event):
event.current_buffer.insert_text("\n")
# Enter to submit (unless in multiline mode)
@kb.add("enter")
def _(event):
if self.config.multiline_mode:
event.current_buffer.insert_text("\n")
else:
event.current_buffer.validate_and_handle()
# Ctrl+D to submit in multiline mode
@kb.add("c-d")
def _(event):
event.current_buffer.validate_and_handle()
session_kwargs = {
"history": FileHistory(str(self._history_file)),
"auto_suggest": AutoSuggestFromHistory(),
"key_bindings": kb,
"multiline": self.config.multiline_mode,
}
if self.config.enable_completions:
completer = self.completer.create_prompt_toolkit_completer()
if completer:
from prompt_toolkit.completion import ThreadedCompleter
session_kwargs["completer"] = ThreadedCompleter(completer)
if self.config.vi_mode:
from prompt_toolkit.enums import EditingMode
session_kwargs["editing_mode"] = EditingMode.VI
return PromptSession(**session_kwargs)
def _get_style(self):
"""Get prompt_toolkit style."""
if not _check_prompt_toolkit() or not self.config.pretty:
return None
from prompt_toolkit.styles import Style
style_dict = {}
if self.config.user_input_color:
style_dict[""] = self.config.user_input_color
return Style.from_dict(style_dict) if style_dict else None
def get_input(self, prompt: str = "You: ") -> str:
"""
Get input from user.
This is SYNCHRONOUS - never call from inside asyncio.run().
"""
if self._prompt_session is None:
self._prompt_session = self._create_prompt_session()
if self._prompt_session:
try:
style = self._get_style()
return self._prompt_session.prompt(prompt, style=style)
except KeyboardInterrupt:
return ""
except EOFError:
return "/exit"
else:
# Fallback to basic input
try:
return input(prompt)
except KeyboardInterrupt:
return ""
except EOFError:
return "/exit"
def rule(self) -> None:
"""Print a horizontal rule."""
if self.console and self.config.pretty:
self.console.rule(style=self.config.user_input_color)
else:
print()
def print_welcome(self, model: str = "gpt-4o-mini", tools_count: int = 0, session_id: str = "") -> None:
"""Print welcome message."""
if self.console and self.config.pretty:
from rich.panel import Panel
from rich.text import Text
welcome = Text()
welcome.append("PraisonAI Interactive Mode\n", style="bold cyan")
welcome.append("Type your prompt, use ", style="dim")
welcome.append("/help", style="bold green")
welcome.append(" for commands, ", style="dim")
welcome.append("/exit", style="bold yellow")
welcome.append(" to quit\n", style="dim")
welcome.append(f"Model: {model}", style="dim cyan")
if tools_count:
welcome.append(f" | Tools: {tools_count}", style="dim")
if session_id:
welcome.append(f" | Session: {session_id[:8]}", style="dim")
self.console.print(Panel(welcome, border_style="blue", padding=(0, 1)))
else:
print("PraisonAI Interactive Mode")
print("Type your prompt, use /help for commands, /exit to quit")
print(f"Model: {model} | Tools: {tools_count}")
def print_user_input(self, text: str) -> None:
"""Echo user input with styling."""
if self.console and self.config.pretty:
from rich.text import Text
self.console.print(Text(text, style=self.config.user_input_color))
def print_assistant_start(self) -> None:
"""Print assistant response start marker."""
if self.console and self.config.pretty:
self.console.print("\n[bold cyan]Assistant:[/bold cyan]")
else:
print("\nAssistant:")
def print_assistant_response(self, response: str) -> None:
"""Print assistant response with markdown formatting."""
if self.console and self.config.pretty:
from rich.markdown import Markdown
from rich.panel import Panel
try:
md = Markdown(response)
self.console.print(Panel(md, border_style="cyan", padding=(0, 1)))
except Exception:
self.console.print(response)
else:
print(response)
def stream_response(self, text: str, final: bool = False) -> MarkdownStream:
"""Get or update streaming response."""
if not hasattr(self, '_current_stream') or self._current_stream is None:
self._current_stream = MarkdownStream()
self._current_stream.update(text, final=final)
if final:
self._current_stream = None
return self._current_stream
def tool_output(self, message: str) -> None:
"""Print tool output."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_output_color}]⚙ {message}[/]")
else:
print(f"⚙ {message}")
def tool_error(self, message: str) -> None:
"""Print tool error."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_error_color}]✗ {message}[/]")
else:
print(f"✗ {message}", file=sys.stderr)
def tool_warning(self, message: str) -> None:
"""Print tool warning."""
if self.console and self.config.pretty:
self.console.print(f"[{self.config.tool_warning_color}]⚠ {message}[/]")
else:
print(f"⚠ {message}", file=sys.stderr)
def info(self, message: str) -> None:
"""Print info message."""
if self.console and self.config.pretty:
self.console.print(f"[dim]ℹ {message}[/dim]")
else:
print(f"ℹ {message}")
def success(self, message: str) -> None:
"""Print success message."""
if self.console and self.config.pretty:
self.console.print(f"[green]✓ {message}[/green]")
else:
print(f"✓ {message}")
def print_help(self, commands: Dict[str, str]) -> None:
"""Print help for available commands."""
if self.console and self.config.pretty:
from rich.table import Table
table = Table(title="Available Commands", border_style="blue")
table.add_column("Command", style="green")
table.add_column("Description", style="dim")
for cmd, desc in sorted(commands.items()):
table.add_row(f"/{cmd}", desc)
self.console.print(table)
else:
print("\nAvailable Commands:")
for cmd, desc in sorted(commands.items()):
print(f" /{cmd:<12} - {desc}")
def confirm(self, message: str, default: bool = False) -> bool:
"""Ask for confirmation."""
suffix = " [Y/n]: " if default else " [y/N]: "
if self.console and self.config.pretty:
self.console.print(f"[yellow]? {message}[/yellow]", end="")
try:
response = input(suffix).strip().lower()
if not response:
return default
return response in ("y", "yes")
except (EOFError, KeyboardInterrupt):
return False
def add_commands(self, commands: Dict[str, str]) -> None:
"""Add commands for completion."""
self.completer.commands.update(commands)
def add_files(self, files: List[str]) -> None:
"""Add files for completion."""
self.completer.add_files(files)
def add_symbols(self, symbols: List[str]) -> None:
"""Add symbols for completion."""
self.completer.add_symbols(symbols)
@@ -0,0 +1,297 @@
"""
Interactive REPL for PraisonAI.
This is the main entry point for interactive chat mode.
Uses PraisonIO for input/output (prompt_toolkit is used SYNCHRONOUSLY).
"""
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional
from .praison_io import PraisonIO, IOConfig
logger = logging.getLogger(__name__)
@dataclass
class REPLConfig:
"""Configuration for the interactive REPL."""
model: str = "gpt-4o-mini"
verbose: bool = False
memory: bool = False
tools: Optional[List] = None
session_id: Optional[str] = None
continue_session: bool = False
workspace: Optional[str] = None
autonomy: bool = True
# Default slash commands
DEFAULT_COMMANDS: Dict[str, str] = {
"help": "Show available commands",
"exit": "Exit interactive mode",
"quit": "Exit interactive mode",
"clear": "Clear conversation history",
"session": "Show current session info",
"model": "Show or change model",
"cost": "Show token usage and cost",
"history": "Show conversation history",
"export": "Export conversation to file",
"compact": "Toggle compact output mode",
"multiline": "Toggle multiline input mode",
}
class InteractiveREPL:
"""
Interactive REPL for PraisonAI chat.
Key design:
- Uses PraisonIO for all I/O (prompt_toolkit used synchronously)
- Agent execution is async but input is sync
- Clean separation between I/O and agent logic
"""
def __init__(
self,
config: Optional[REPLConfig] = None,
io_config: Optional[IOConfig] = None,
):
self.config = config or REPLConfig()
self.io = PraisonIO(io_config)
self._agent = None
self._running = False
self._conversation_history: List[Dict] = []
self._session_id: Optional[str] = None
self._total_tokens = 0
self._total_cost = 0.0
# Setup commands
self.io.add_commands(DEFAULT_COMMANDS)
def _get_agent(self):
"""Lazy-load the agent."""
if self._agent is None:
try:
from praisonaiagents import Agent
agent_kwargs = {
"name": "InteractiveAgent",
"role": "Assistant",
"goal": "Help the user with their requests",
"instructions": "You are a helpful AI assistant. Be concise and helpful.",
}
if self.config.model:
agent_kwargs["llm"] = self.config.model
if self.config.memory:
agent_kwargs["memory"] = True
if self.config.tools:
agent_kwargs["tools"] = self.config.tools
self._agent = Agent(**agent_kwargs)
except ImportError as e:
self.io.tool_error(f"Failed to import praisonaiagents: {e}")
raise
return self._agent
def _handle_command(self, command: str) -> bool:
"""
Handle slash commands.
Returns True if command was handled, False otherwise.
"""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
self.io.info("Goodbye!")
return True
elif cmd == "help":
self.io.print_help(DEFAULT_COMMANDS)
return True
elif cmd == "clear":
self._conversation_history.clear()
self.io.success("Conversation cleared.")
return True
elif cmd == "session":
if self._session_id:
self.io.info(f"Session: {self._session_id}")
else:
self.io.info("No active session")
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None # Reset agent to use new model
self.io.success(f"Model changed to: {args}")
else:
self.io.info(f"Current model: {self.config.model}")
return True
elif cmd == "cost":
self.io.info(f"Total tokens: {self._total_tokens}")
self.io.info(f"Estimated cost: ${self._total_cost:.4f}")
return True
elif cmd == "history":
if not self._conversation_history:
self.io.info("No conversation history")
else:
for i, msg in enumerate(self._conversation_history[-10:], 1):
role = msg.get("role", "unknown")
content = msg.get("content", "")[:100]
self.io.info(f"{i}. [{role}] {content}...")
return True
elif cmd == "export":
filename = args or "conversation.txt"
try:
with open(filename, "w") as f:
for msg in self._conversation_history:
f.write(f"[{msg.get('role', 'unknown')}]\n")
f.write(f"{msg.get('content', '')}\n\n")
self.io.success(f"Exported to {filename}")
except Exception as e:
self.io.tool_error(f"Export failed: {e}")
return True
elif cmd == "compact":
self.io.config.pretty = not self.io.config.pretty
mode = "disabled" if self.io.config.pretty else "enabled"
self.io.info(f"Compact mode {mode}")
return True
elif cmd == "multiline":
self.io.config.multiline_mode = not self.io.config.multiline_mode
mode = "enabled" if self.io.config.multiline_mode else "disabled"
self.io.info(f"Multiline mode {mode}")
return True
else:
self.io.tool_warning(f"Unknown command: /{cmd}")
self.io.info("Type /help for available commands")
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response."""
try:
agent = self._get_agent()
# Add to history
self._conversation_history.append({
"role": "user",
"content": prompt
})
# Execute
response = agent.start(prompt)
# Add response to history
if response:
self._conversation_history.append({
"role": "assistant",
"content": str(response)
})
return str(response) if response else None
except Exception as e:
logger.exception("Error executing prompt")
self.io.tool_error(f"Error: {e}")
return None
def run(self) -> None:
"""
Run the interactive REPL.
This is the main entry point. It runs synchronously,
using prompt_toolkit for input (which must be sync).
"""
self._running = True
# Get tools count for display
tools_count = len(self.config.tools) if self.config.tools else 0
# Generate session ID
import uuid
self._session_id = self.config.session_id or str(uuid.uuid4())
# Print welcome
self.io.print_welcome(
model=self.config.model,
tools_count=tools_count,
session_id=self._session_id
)
while self._running:
try:
# Get input (SYNC - this is the key!)
user_input = self.io.get_input("You: ").strip()
if not user_input:
continue
# Handle slash commands
if user_input.startswith("/"):
self._handle_command(user_input)
continue
# Show thinking indicator
self.io.print_assistant_start()
# Execute prompt
response = self._execute_prompt(user_input)
# Display response
if response:
self.io.print_assistant_response(response)
except KeyboardInterrupt:
self.io.info("\nUse /exit to quit")
except EOFError:
self._running = False
self.io.info("Goodbye!")
except Exception as e:
logger.exception("Error in REPL loop")
self.io.tool_error(f"Error: {e}")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt and return the response."""
return self._execute_prompt(prompt)
def start_interactive(
model: str = "gpt-4o-mini",
verbose: bool = False,
memory: bool = False,
tools: Optional[List] = None,
session_id: Optional[str] = None,
**kwargs
) -> None:
"""
Start the interactive REPL.
This is the main entry point for `praisonai chat` and `praisonai` (no args).
"""
config = REPLConfig(
model=model,
verbose=verbose,
memory=memory,
tools=tools,
session_id=session_id,
)
repl = InteractiveREPL(config=config)
repl.run()
@@ -0,0 +1,456 @@
"""
PraisonAI Split-Pane TUI Application.
Full-screen terminal UI with:
- Fixed bottom input pane (always visible)
- Scrollable top output pane
- Direct response (no streaming - faster)
- Activity indicators showing agent status
Uses prompt_toolkit Application for true split-pane layout.
"""
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from datetime import datetime
# ============================================================================
# Branding - Import from unified source
# ============================================================================
from praisonai.cli.branding import get_logo, get_version
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class SplitTUIConfig:
"""Configuration for the Split TUI application."""
model: str = "gpt-4o-mini"
show_logo: bool = True
show_status_bar: bool = True
session_id: Optional[str] = None
workspace: Optional[str] = None
history_file: Optional[str] = None
# ============================================================================
# Message Types
# ============================================================================
@dataclass
class ChatMessage:
"""A chat message."""
role: str # "user", "assistant", "system", "status"
content: str
timestamp: datetime = field(default_factory=datetime.now)
# ============================================================================
# Split TUI Application
# ============================================================================
class SplitTUI:
"""
Split-pane TUI with fixed bottom input and scrollable top output.
Key features:
- Input box ALWAYS visible at bottom
- Output scrolls above independently
- Direct response (no streaming) for speed
- Activity indicators during processing
"""
def __init__(self, config: Optional[SplitTUIConfig] = None):
self.config = config or SplitTUIConfig()
self.messages: List[ChatMessage] = []
self._running = False
self._agent = None
self._processing = False
self._status_text = ""
# Terminal size
self.term_width, self.term_height = shutil.get_terminal_size((80, 24))
# Session ID
import uuid
self.session_id = self.config.session_id or str(uuid.uuid4())[:8]
def _get_agent(self):
"""Lazy-load the agent with verbose=False for no streaming."""
if self._agent is None:
try:
from praisonaiagents import Agent
self._agent = Agent(
name="Praison",
role="AI Assistant",
goal="Help the user with their requests",
instructions="You are Praison AI, a helpful assistant. Be concise and helpful.",
llm=self.config.model,
)
except ImportError as e:
raise RuntimeError(f"Failed to import praisonaiagents: {e}")
return self._agent
def _format_output(self) -> str:
"""Format all messages for the output pane."""
lines = []
# Logo
if self.config.show_logo and not self.messages:
logo = get_logo(self.term_width)
lines.append(logo.strip())
lines.append("")
lines.append(f" v{get_version()} · Model: {self.config.model}")
lines.append("")
lines.append(" Type your message and press Enter. Use /help for commands.")
lines.append("")
# Messages
for msg in self.messages:
if msg.role == "user":
lines.append(f"› {msg.content}")
lines.append("")
elif msg.role == "assistant":
lines.append(f"● {msg.content}")
lines.append("")
elif msg.role == "system":
lines.append(f" {msg.content}")
lines.append("")
elif msg.role == "status":
lines.append(f" ⏳ {msg.content}")
# Processing indicator
if self._processing:
lines.append(f" ⏳ {self._status_text}")
return "\n".join(lines)
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
return True
elif cmd == "help":
help_text = """Available Commands:
/help Show this help
/exit, /quit Exit Praison AI
/clear Clear conversation
/model [name] Show or change model
/new Start new conversation"""
self.messages.append(ChatMessage(role="system", content=help_text))
return True
elif cmd == "clear":
self.messages.clear()
return True
elif cmd == "new":
self.messages.clear()
import uuid
self.session_id = str(uuid.uuid4())[:8]
self.messages.append(ChatMessage(role="system", content=f"New session: {self.session_id}"))
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None
self.messages.append(ChatMessage(role="system", content=f"Model changed to: {args}"))
else:
self.messages.append(ChatMessage(role="system", content=f"Current model: {self.config.model}"))
return True
else:
self.messages.append(ChatMessage(role="system", content=f"Unknown command: /{cmd}. Use /help for available commands."))
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response (direct, no streaming)."""
import sys
import io
try:
agent = self._get_agent()
# Suppress agent's Rich output by redirecting stdout/stderr
old_stdout = sys.stdout
old_stderr = sys.stderr
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
try:
response = agent.start(prompt)
finally:
# Restore stdout/stderr
sys.stdout = old_stdout
sys.stderr = old_stderr
return str(response) if response else None
except Exception as e:
return f"Error: {e}"
def _run_with_status(self, prompt: str) -> Optional[str]:
"""Run prompt with status updates."""
self._processing = True
self._status_text = "Praison AI is thinking..."
# Execute in background
result = [None]
error = [None]
def execute():
try:
result[0] = self._execute_prompt(prompt)
except Exception as e:
error[0] = str(e)
thread = threading.Thread(target=execute)
thread.start()
# Update status while waiting
start_time = time.time()
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
idx = 0
while thread.is_alive():
elapsed = time.time() - start_time
self._status_text = f"{spinner[idx]} Praison AI is thinking... ({elapsed:.1f}s)"
idx = (idx + 1) % len(spinner)
time.sleep(0.1)
thread.join()
self._processing = False
self._status_text = ""
if error[0]:
return f"Error: {error[0]}"
return result[0]
def run(self) -> None:
"""Run the split-pane TUI."""
self._running = True
# Try to use prompt_toolkit for better experience
try:
self._run_with_prompt_toolkit()
except ImportError:
# Fallback to simple mode
self._run_simple()
def _run_simple(self) -> None:
"""Simple fallback mode without prompt_toolkit."""
# Print welcome
print(self._format_output())
while self._running:
try:
# Status bar
if self.config.show_status_bar:
status = f" ? for help | {self.config.model} | Session: {self.session_id} "
print(f"\033[90m{status}\033[0m")
# Get input
user_input = input("› ").strip()
if not user_input:
continue
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
# Print system messages
for msg in self.messages:
if msg.role == "system":
print(f" {msg.content}")
self.messages = [m for m in self.messages if m.role != "system"]
continue
# Add user message
self.messages.append(ChatMessage(role="user", content=user_input))
# Execute with status
print(" ⏳ Praison AI is thinking...")
response = self._execute_prompt(user_input)
if response:
self.messages.append(ChatMessage(role="assistant", content=response))
print(f"\n● {response}\n")
except KeyboardInterrupt:
print("\n Use /exit to quit")
except EOFError:
self._running = False
print("\n Goodbye from Praison AI!")
def _run_with_prompt_toolkit(self) -> None:
"""Run with prompt_toolkit for split-pane layout."""
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout.containers import HSplit, VSplit, Window
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
from prompt_toolkit.history import FileHistory
# Setup history
history_file = self.config.history_file or str(Path.home() / ".praison" / "history")
Path(history_file).parent.mkdir(parents=True, exist_ok=True)
# Output buffer (read-only, for displaying conversation)
output_text = [self._format_output()]
def get_output_text():
return output_text[0]
# Input buffer
input_buffer = Buffer(
multiline=False,
history=FileHistory(history_file),
)
# Key bindings
kb = KeyBindings()
@kb.add("enter")
def handle_enter(event):
user_input = input_buffer.text.strip()
if not user_input:
return
input_buffer.reset()
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
output_text[0] = self._format_output()
return
# Add user message
self.messages.append(ChatMessage(role="user", content=user_input))
# Show processing status
self.messages.append(ChatMessage(role="status", content="Praison AI is thinking..."))
output_text[0] = self._format_output()
# Execute (this blocks but that's OK for direct response)
response = self._execute_prompt(user_input)
# Remove status message
self.messages = [m for m in self.messages if m.role != "status"]
if response:
self.messages.append(ChatMessage(role="assistant", content=response))
output_text[0] = self._format_output()
@kb.add("c-c")
def handle_ctrl_c(event):
input_buffer.reset()
@kb.add("c-d")
def handle_ctrl_d(event):
if not input_buffer.text:
self._running = False
event.app.exit()
@kb.add("c-q")
def handle_ctrl_q(event):
self._running = False
event.app.exit()
# Style
style = Style.from_dict({
"output-area": "bg:#0d0d0d",
"input-area": "bg:#1a1a1a",
"status-bar": "bg:#333333 #888888",
"prompt": "#00aaff bold",
})
# Status bar text
def get_status_bar():
left = "? for help"
center = self.config.model
right = f"Session: {self.session_id}"
return f" {left} | {center} | {right} "
# Layout: Output (top, scrollable) + Status bar + Input (bottom, fixed)
layout = Layout(
HSplit([
# Output area (takes remaining space)
Window(
content=FormattedTextControl(get_output_text),
wrap_lines=True,
style="class:output-area",
),
# Status bar (1 line)
Window(
content=FormattedTextControl(get_status_bar),
height=1,
style="class:status-bar",
),
# Input area (fixed at bottom, 3 lines for visibility)
HSplit([
Window(height=1), # Spacer
VSplit([
Window(
content=FormattedTextControl([("class:prompt", "› ")]),
width=2,
),
Window(
content=BufferControl(buffer=input_buffer),
wrap_lines=True,
),
]),
Window(height=1), # Spacer
], height=3, style="class:input-area"),
])
)
# Create application
app = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
)
# Run
app.run()
print("\n Goodbye from Praison AI!")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt (non-interactive)."""
return self._execute_prompt(prompt)
def start_split_tui(
model: str = "gpt-4o-mini",
show_logo: bool = True,
**kwargs
) -> None:
"""Start the split-pane TUI application."""
config = SplitTUIConfig(
model=model,
show_logo=show_logo,
)
tui = SplitTUI(config=config)
tui.run()
@@ -0,0 +1,457 @@
"""
PraisonAI Interactive TUI Application.
Full-screen terminal UI with:
- Sticky bottom input box (like Aider/Codex/Claude Code)
- Scrollable output area above
- ASCII art logo banner
- Status bar with model/context info
- Clean, spacious design inspired by Claude Code
Uses prompt_toolkit for the full-screen application.
"""
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
# Lazy imports for performance
_PT_AVAILABLE = None
_RICH_AVAILABLE = None
def _check_prompt_toolkit():
global _PT_AVAILABLE
if _PT_AVAILABLE is None:
try:
import prompt_toolkit # noqa: F401
_PT_AVAILABLE = True
except ImportError:
_PT_AVAILABLE = False
return _PT_AVAILABLE
def _check_rich():
global _RICH_AVAILABLE
if _RICH_AVAILABLE is None:
try:
import rich # noqa: F401
_RICH_AVAILABLE = True
except ImportError:
_RICH_AVAILABLE = False
return _RICH_AVAILABLE
# ============================================================================
# Branding - Import from unified source
# ============================================================================
from praisonai.cli.branding import get_logo, get_version
# ============================================================================
# Color Scheme
# ============================================================================
@dataclass
class ColorScheme:
"""Color scheme for the TUI."""
# Primary colors
primary: str = "#00aaff" # Cyan-blue (PraisonAI brand)
secondary: str = "#888888" # Gray
accent: str = "#00cc00" # Green
# Text colors
text: str = "#ffffff" # White
text_dim: str = "#666666" # Dim gray
text_muted: str = "#444444" # Very dim
# Status colors
success: str = "#00cc00" # Green
warning: str = "#ffaa00" # Orange
error: str = "#ff4444" # Red
info: str = "#00aaff" # Blue
# UI elements
border: str = "#333333" # Dark gray
input_bg: str = "#1a1a1a" # Very dark
output_bg: str = "#0d0d0d" # Almost black
DEFAULT_COLORS = ColorScheme()
# ============================================================================
# TUI Configuration
# ============================================================================
@dataclass
class TUIConfig:
"""Configuration for the TUI application."""
# Model settings
model: str = "gpt-4o-mini"
# Display settings
show_logo: bool = True
show_tips: bool = True
show_status_bar: bool = True
compact_mode: bool = False
# Colors
colors: ColorScheme = field(default_factory=ColorScheme)
# Input settings
multiline: bool = False
vi_mode: bool = False
# History
history_file: Optional[str] = None
# Session
session_id: Optional[str] = None
workspace: Optional[str] = None
# ============================================================================
# Message Types
# ============================================================================
@dataclass
class Message:
"""A chat message."""
role: str # "user", "assistant", "system", "tool"
content: str
timestamp: datetime = field(default_factory=datetime.now)
metadata: Dict = field(default_factory=dict)
# ============================================================================
# TUI Application
# ============================================================================
class PraisonTUI:
"""
Full-screen TUI application for PraisonAI.
Features:
- Sticky bottom input box
- Scrollable output area
- ASCII art logo
- Status bar
- Clean, spacious design
"""
def __init__(self, config: Optional[TUIConfig] = None):
self.config = config or TUIConfig()
self.messages: List[Message] = []
self._running = False
self._agent = None
self._total_tokens = 0
self._total_cost = 0.0
# Get terminal size
self.term_width, self.term_height = shutil.get_terminal_size((80, 24))
# Generate session ID
import uuid
self.session_id = self.config.session_id or str(uuid.uuid4())[:8]
def _get_agent(self):
"""Lazy-load the agent."""
if self._agent is None:
try:
from praisonaiagents import Agent
self._agent = Agent(
name="PraisonAgent",
role="Assistant",
goal="Help the user with their requests",
instructions="You are a helpful AI assistant. Be concise and helpful.",
llm=self.config.model,
)
except ImportError as e:
raise RuntimeError(f"Failed to import praisonaiagents: {e}")
return self._agent
def _render_logo(self) -> str:
"""Render the ASCII logo with colors."""
if not self.config.show_logo:
return ""
logo = get_logo(self.term_width)
version = get_version()
info_line = f"v{version} · Model: {self.config.model} · Session: {self.session_id}"
return logo.strip() + "\n" + info_line
def _render_welcome(self) -> str:
"""Render welcome message."""
lines = []
# Logo
if self.config.show_logo:
lines.append(self._render_logo())
lines.append("")
# Tips
if self.config.show_tips:
tips = [
"Type your message and press Enter to send",
"Use /help for commands, /exit to quit",
"Press ? for keyboard shortcuts",
]
lines.append(" " + tips[0])
lines.append("")
return "\n".join(lines)
def _render_status_bar(self) -> str:
"""Render the status bar."""
if not self.config.show_status_bar:
return ""
left = f"? for shortcuts"
center = f"{self.config.model}"
right = f"Session: {self.session_id}"
# Calculate spacing
total_len = len(left) + len(center) + len(right)
available = self.term_width - total_len - 4
if available > 0:
left_space = available // 2
right_space = available - left_space
return f" {left}{' ' * left_space}{center}{' ' * right_space}{right} "
else:
return f" {center} "
def _format_message(self, msg: Message) -> str:
"""Format a message for display."""
if msg.role == "user":
prefix = "› "
return f"{prefix}{msg.content}"
elif msg.role == "assistant":
prefix = "● "
return f"{prefix}{msg.content}"
elif msg.role == "tool":
prefix = "⚙ "
return f"{prefix}{msg.content}"
else:
return msg.content
def _handle_command(self, command: str) -> bool:
"""Handle slash commands. Returns True if handled."""
parts = command.split(maxsplit=1)
cmd = parts[0].lower().lstrip("/")
args = parts[1] if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self._running = False
return True
elif cmd == "help":
help_text = """
Available Commands:
/help Show this help
/exit, /quit Exit interactive mode
/clear Clear conversation
/model [name] Show or change model
/cost Show token usage
/compact Toggle compact mode
/multiline Toggle multiline input
"""
self.messages.append(Message(role="system", content=help_text.strip()))
return True
elif cmd == "clear":
self.messages.clear()
return True
elif cmd == "model":
if args:
self.config.model = args
self._agent = None
self.messages.append(Message(role="system", content=f"Model changed to: {args}"))
else:
self.messages.append(Message(role="system", content=f"Current model: {self.config.model}"))
return True
elif cmd == "cost":
self.messages.append(Message(
role="system",
content=f"Tokens: {self._total_tokens} | Cost: ${self._total_cost:.4f}"
))
return True
elif cmd == "compact":
self.config.compact_mode = not self.config.compact_mode
mode = "enabled" if self.config.compact_mode else "disabled"
self.messages.append(Message(role="system", content=f"Compact mode {mode}"))
return True
elif cmd == "multiline":
self.config.multiline = not self.config.multiline
mode = "enabled" if self.config.multiline else "disabled"
self.messages.append(Message(role="system", content=f"Multiline mode {mode}"))
return True
else:
self.messages.append(Message(role="system", content=f"Unknown command: /{cmd}"))
return True
def _execute_prompt(self, prompt: str) -> Optional[str]:
"""Execute a prompt and return the response."""
try:
agent = self._get_agent()
response = agent.start(prompt)
return str(response) if response else None
except Exception as e:
return f"Error: {e}"
def run(self) -> None:
"""Run the TUI application."""
self._running = True
# Print welcome
print(self._render_welcome())
# Setup history
history_file = self.config.history_file or str(Path.home() / ".praison" / "history")
Path(history_file).parent.mkdir(parents=True, exist_ok=True)
# Create prompt session
if _check_prompt_toolkit():
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
# Key bindings
kb = KeyBindings()
@kb.add("c-c")
def _(event):
event.current_buffer.reset()
@kb.add("c-d")
def _(event):
if not event.current_buffer.text:
event.app.exit()
# Style
style = Style.from_dict({
"": self.config.colors.accent,
"prompt": self.config.colors.primary,
})
session = PromptSession(
history=FileHistory(history_file),
auto_suggest=AutoSuggestFromHistory(),
key_bindings=kb,
style=style,
multiline=self.config.multiline,
)
def get_input():
return session.prompt(
[("class:prompt", "› ")],
placeholder="Type your message or @path/to/file"
)
else:
def get_input():
return input("› ")
# Main loop
while self._running:
try:
# Print status bar
if self.config.show_status_bar and not self.config.compact_mode:
status = self._render_status_bar()
if _check_rich():
from rich.console import Console
Console().print(f"[dim]{status}[/dim]")
else:
print(f"\033[90m{status}\033[0m")
# Get input
user_input = get_input().strip()
if not user_input:
continue
# Handle commands
if user_input.startswith("/"):
self._handle_command(user_input)
# Print system messages
for msg in self.messages:
if msg.role == "system":
print(self._format_message(msg))
self.messages = [m for m in self.messages if m.role != "system"]
continue
# Add user message
self.messages.append(Message(role="user", content=user_input))
# Show thinking indicator
if _check_rich():
from rich.console import Console
Console().print("[dim]Thinking...[/dim]")
# Execute
response = self._execute_prompt(user_input)
if response:
self.messages.append(Message(role="assistant", content=response))
# Display response
if _check_rich():
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
console = Console()
try:
md = Markdown(response)
console.print(Panel(md, border_style="cyan", padding=(0, 1)))
except Exception:
console.print(f"● {response}")
else:
print(f"● {response}")
print() # Spacing
except KeyboardInterrupt:
print("\nUse /exit to quit")
except EOFError:
self._running = False
print("\nGoodbye!")
except Exception as e:
print(f"Error: {e}")
print("Goodbye!")
def run_single(self, prompt: str) -> Optional[str]:
"""Run a single prompt."""
return self._execute_prompt(prompt)
def start_tui(
model: str = "gpt-4o-mini",
show_logo: bool = True,
compact: bool = False,
**kwargs
) -> None:
"""Start the TUI application."""
config = TUIConfig(
model=model,
show_logo=show_logo,
compact_mode=compact,
)
tui = PraisonTUI(config=config)
tui.run()
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==3.7.3 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.7.9 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -358,6 +358,69 @@ class AIGenerator:
pass
return self._fast_context_handler
def _detect_feature_capabilities(self, slug_str: str, source_info: Dict[str, Any]) -> Dict[str, Any]:
"""Detect feature capabilities from source analysis."""
caps = {
"supports_bool": True,
"supports_list": False,
"supports_dict": False,
"supports_presets": [],
"supports_url": False,
"usage_forms": [],
"decorator": None,
"built_in_items": [],
}
# Feature-specific detection based on slug
if slug_str == "tools":
caps["supports_list"] = True
caps["supports_bool"] = False
caps["decorator"] = "@tool"
caps["built_in_items"] = ["duckduckgo", "wikipedia_tools", "read_file", "execute_command"]
caps["usage_forms"] = [
"- List of tools: `tools=[my_func, another_func]`",
"- Using @tool decorator to create custom tools",
"- Using built-in tools: duckduckgo, wikipedia_tools, etc.",
]
elif slug_str == "memory":
caps["supports_dict"] = True
caps["supports_presets"] = ["sqlite", "redis", "postgres", "qdrant", "chroma"]
caps["supports_url"] = True
caps["usage_forms"] = [
"- Boolean: `memory=True`",
"- Preset: `memory=\"sqlite\"` or `memory=\"redis\"`",
"- URL: `memory=\"redis://localhost:6379\"`",
"- Dict: `memory={\"provider\": \"qdrant\"}`",
]
elif slug_str == "knowledge":
caps["supports_dict"] = True
caps["supports_list"] = True
caps["usage_forms"] = [
"- Boolean: `knowledge=True`",
"- List of sources: `knowledge=[\"file.pdf\", \"doc.txt\"]`",
"- Dict config: `knowledge={\"sources\": [...]}`",
]
elif slug_str == "guardrails":
caps["supports_list"] = True
caps["decorator"] = "guardrail function"
caps["usage_forms"] = [
"- Boolean: `guardrails=True`",
"- List of functions: `guardrails=[my_guardrail_func]`",
"- Custom guardrail function returning GuardrailResult",
]
elif slug_str == "streaming":
caps["supports_bool"] = True
caps["usage_forms"] = [
"- Boolean: `output=\"stream\"` or iterate over agent.start()",
"- Streaming with callbacks",
]
else:
# Generic detection from source
caps["usage_forms"] = [f"- Boolean: `{slug_str}=True`"]
logger.debug(f"Feature '{slug_str}' capabilities: {caps}")
return caps
def _get_agent(self):
"""Lazy-load the generation agent."""
if self._agent is None:
@@ -591,14 +654,19 @@ ISSUES: List any issues found (or "None")
context = {}
slug_str = slug.normalised
logger.debug(f"Gathering context for feature '{slug_str}', artifact '{artifact_type.value}'")
# Use FastContext to find relevant code in SDK
fast_context = self._get_fast_context_handler()
if fast_context and self.sdk_root:
logger.debug(f"FastContext available, searching in {self.sdk_root}")
try:
# Search for feature-related code
query = f"Find {slug_str} implementation, usage examples, and API"
matches = fast_context.search_context(query, str(self.sdk_root), use_llm_keywords=False)
logger.debug(f"FastContext found {len(matches)} matches for '{query}'")
# Read matched files
for match in matches[:3]:
file_path = self.sdk_root / match.get('file', '')
@@ -606,10 +674,11 @@ ISSUES: List any issues found (or "None")
try:
content = file_path.read_text(encoding="utf-8")[:2000]
context[f"sdk_{file_path.stem}"] = content
logger.debug(f"Added SDK context: {file_path.name} ({len(content)} chars)")
except Exception:
pass
except Exception:
pass
except Exception as e:
logger.debug(f"FastContext search failed: {e}")
# Get SDK module info (fallback)
if self.sdk_root and not context:
@@ -653,6 +722,7 @@ ISSUES: List any issues found (or "None")
if doc_path.exists():
context[f"existing_{doc_type}"] = doc_path.read_text(encoding="utf-8")[:2000]
logger.debug(f"Context gathered for '{slug_str}': {list(context.keys())}")
return context
def _read_sdk_module(self, sdk_path: Path) -> str:
@@ -866,17 +936,26 @@ print(result)
"",
]
# Add usage forms guidance (common patterns for all features)
param_name = slug_str.replace("-", "_")
prompt_parts.extend([
"## USAGE FORMS TO DEMONSTRATE (MUST cover ALL these progressively):",
f"- Boolean: `{param_name}=True` - Enable with defaults",
f"- String preset: `{param_name}=\"sqlite\"` or `{param_name}=\"redis\"` - Use predefined backend",
f"- URL: `{param_name}=\"redis://localhost:6379\"` - Backend-specific connection",
f"- Dict config: `{param_name}={{\"backend\": \"postgres\", \"user_id\": \"u1\"}}` - Custom config",
f"- Array + overrides: `{param_name}=[\"redis\", {{\"user_id\": \"u1\"}}]` - Preset + customization",
"",
])
# Get dynamic feature capabilities - NO hardcoded usage forms
capabilities = self._detect_feature_capabilities(slug_str, source_info)
# Add ONLY the usage forms this feature actually supports
prompt_parts.append("## USAGE FORMS TO DEMONSTRATE (cover these progressively):")
for usage_form in capabilities["usage_forms"]:
prompt_parts.append(usage_form)
# Add decorator info if applicable
if capabilities.get("decorator"):
prompt_parts.append(f"- MUST demonstrate the {capabilities['decorator']} pattern")
# Add built-in items if applicable
if capabilities.get("built_in_items"):
items = ", ".join(capabilities["built_in_items"][:4])
prompt_parts.append(f"- Built-in options to show: {items}")
prompt_parts.append("")
logger.debug(f"Feature '{slug_str}' usage forms: {capabilities['usage_forms']}")
# Add dynamically extracted parameters if available
main_class = source_info.get("main_class")
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.7.3"
__version__ = "3.7.9"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.11.2",
"praisonaiagents>=0.11.5",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
@@ -0,0 +1,848 @@
"""
Tests for PraisonAI Async TUI Application.
"""
class TestAsyncTUIConfig:
"""Tests for AsyncTUIConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.async_tui import AsyncTUIConfig
config = AsyncTUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_status_bar is True
assert config.session_id is None
assert config.workspace is None
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.async_tui import AsyncTUIConfig
config = AsyncTUIConfig(
model="gpt-4o",
show_logo=False,
session_id="test123",
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.session_id == "test123"
class TestChatMessage:
"""Tests for ChatMessage dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.async_tui import ChatMessage
msg = ChatMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
def test_message_roles(self):
"""Test different message roles."""
from praisonai.cli.interactive.async_tui import ChatMessage
roles = ["user", "assistant", "system", "status"]
for role in roles:
msg = ChatMessage(role=role, content="test")
assert msg.role == role
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(50)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.branding import LOGO_SMALL
assert "Praison AI" in LOGO_SMALL
class TestAsyncTUI:
"""Tests for AsyncTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui._processing is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(model="gpt-4o", show_logo=False)
tui = AsyncTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_format_output_empty(self):
"""Test format_output with no messages."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
output = tui._format_output()
assert "Type your message" in output or "gpt-4o-mini" in output
def test_format_output_with_messages(self):
"""Test format_output with messages."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [
ChatMessage(role="user", content="Hello"),
ChatMessage(role="assistant", content="Hi there"),
]
output = tui._format_output()
assert "Hello" in output
assert "Hi there" in output
def test_format_output_with_processing(self):
"""Test format_output shows processing indicator."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = True
tui._status_text = "Thinking..."
output = tui._format_output()
assert "Thinking..." in output
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/help")
assert result is True
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
assert "PageUp" in tui.messages[0].content # Scroll instructions
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
# Clear now adds a confirmation message
assert len(tui.messages) == 1
assert "cleared" in tui.messages[0].content.lower()
def test_handle_command_new(self):
"""Test handling new command."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
old_session = tui.session_id
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/new")
assert result is True
assert len(tui.messages) == 1
assert tui.session_id != old_session
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
assert tui._agent is None
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartAsyncTUI:
"""Tests for start_async_tui function."""
def test_function_exists(self):
"""Test that start_async_tui function exists."""
from praisonai.cli.interactive.async_tui import start_async_tui
assert callable(start_async_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_exists(self):
"""Test LOGO constant exists and has content."""
from praisonai.cli.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.branding import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestMessageFormatting:
"""Tests for message formatting."""
def test_user_message_prefix(self):
"""Test user messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="user", content="test")]
output = tui._format_output()
assert "› test" in output
def test_assistant_message_prefix(self):
"""Test assistant messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="assistant", content="response")]
output = tui._format_output()
assert "● response" in output
def test_status_message_prefix(self):
"""Test status messages have correct prefix."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages = [ChatMessage(role="status", content="thinking")]
output = tui._format_output()
assert "⏳ thinking" in output
class TestNonBlockingExecution:
"""Tests for non-blocking execution features."""
def test_processing_flag(self):
"""Test processing flag is used correctly."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._processing is False
tui._processing = True
tui._status_text = "Working..."
output = tui._format_output()
assert "Working..." in output
def test_status_text_updates(self):
"""Test status text can be updated."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = True
tui._status_text = "Step 1"
output1 = tui._format_output()
assert "Step 1" in output1
tui._status_text = "Step 2"
output2 = tui._format_output()
assert "Step 2" in output2
class TestNewSlashCommands:
"""Tests for newly added slash commands."""
def test_session_command(self):
"""Test /session command shows session info."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/session")
assert result is True
assert len(tui.messages) == 1
assert "Session:" in tui.messages[0].content
assert tui.session_id in tui.messages[0].content
def test_history_command_empty(self):
"""Test /history command with no history."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/history")
assert result is True
assert "No conversation history" in tui.messages[0].content
def test_history_command_with_history(self):
"""Test /history command with conversation history."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._conversation_history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
]
result = tui._handle_command("/history")
assert result is True
assert "[user]" in tui.messages[0].content
def test_cost_command(self):
"""Test /cost command shows token usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._total_tokens = 100
tui._total_cost = 0.001
result = tui._handle_command("/cost")
assert result is True
assert "100" in tui.messages[0].content
def test_compact_command(self):
"""Test /compact command toggles compact mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.compact_mode is False
result = tui._handle_command("/compact")
assert result is True
assert tui.config.compact_mode is True
result = tui._handle_command("/compact")
assert tui.config.compact_mode is False
def test_multiline_command(self):
"""Test /multiline command toggles multiline mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.multiline_mode is False
result = tui._handle_command("/multiline")
assert result is True
assert tui.config.multiline_mode is True
def test_files_command(self):
"""Test /files command lists workspace files."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._workspace_files = ["test.py", "main.py"]
result = tui._handle_command("/files")
assert result is True
assert "@test.py" in tui.messages[0].content
def test_queue_command_empty(self):
"""Test /queue command with empty queue."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/queue")
assert result is True
assert "No prompts in queue" in tui.messages[0].content
def test_queue_command_with_items(self):
"""Test /queue command with queued prompts."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._prompt_queue = ["First prompt", "Second prompt"]
result = tui._handle_command("/queue")
assert result is True
assert "First prompt" in tui.messages[0].content
class TestQueueSystem:
"""Tests for prompt queue system."""
def test_queue_initialization(self):
"""Test queue is initialized empty."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._prompt_queue == []
def test_queue_or_execute_when_not_processing(self):
"""Test _queue_or_execute executes immediately when not processing."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tui._processing = False
# This would normally start background execution
# Just test that it adds user message
tui._queue_or_execute("test prompt")
assert len(tui.messages) == 1
assert tui.messages[0].role == "user"
assert tui.messages[0].content == "test prompt"
class TestFileProcessing:
"""Tests for @file mention processing."""
def test_process_file_mentions_no_mentions(self):
"""Test processing prompt with no @file mentions."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._process_file_mentions("Hello world")
assert result == "Hello world"
def test_process_file_mentions_with_mention(self):
"""Test processing prompt with @file mention."""
from praisonai.cli.interactive.async_tui import AsyncTUI
import tempfile
import os
tui = AsyncTUI()
# Create a temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("test content")
temp_path = f.name
try:
tui.config.workspace = os.path.dirname(temp_path)
filename = os.path.basename(temp_path)
result = tui._process_file_mentions(f"Check @{filename}")
assert "test content" in result
finally:
os.unlink(temp_path)
class TestWorkspaceScanning:
"""Tests for workspace file scanning."""
def test_workspace_files_initialized(self):
"""Test workspace files list is initialized."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert isinstance(tui._workspace_files, list)
def test_scan_workspace_limits_files(self):
"""Test workspace scanning limits files (may exceed slightly due to batch processing)."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
# Verify the limit is approximately enforced (may exceed slightly due to batch)
assert len(tui._workspace_files) <= 1100 # Allow some margin
class TestToolLoading:
"""Tests for tool loading functionality."""
def test_load_tools_returns_list(self):
"""Test _load_tools returns a list."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
tools = tui._load_tools()
assert isinstance(tools, list)
def test_load_tools_method_exists(self):
"""Test _load_tools method exists."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert hasattr(tui, '_load_tools')
assert callable(tui._load_tools)
class TestNewSessionCommands:
"""Tests for session-related commands."""
def test_sessions_command(self):
"""Test /sessions command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/sessions")
assert result is True
# Should have a message about sessions
assert len(tui.messages) >= 1
def test_continue_command(self):
"""Test /continue command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/continue")
assert result is True
assert len(tui.messages) >= 1
def test_import_command_no_args(self):
"""Test /import command without arguments."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/import")
assert result is True
assert "Usage" in tui.messages[0].content
def test_import_command_file_not_found(self):
"""Test /import command with non-existent file."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/import nonexistent_file.json")
assert result is True
assert "not found" in tui.messages[0].content.lower() or "failed" in tui.messages[0].content.lower()
def test_status_command(self):
"""Test /status command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/status")
assert result is True
# Should show runtime status or "not initialized"
assert len(tui.messages) >= 1
class TestRuntimeIntegration:
"""Tests for runtime integration."""
def test_runtime_fields_initialized(self):
"""Test runtime fields are initialized."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui._runtime is None
assert tui._runtime_started is False
def test_start_runtime_method_exists(self):
"""Test _start_runtime method exists."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert hasattr(tui, '_start_runtime')
# It's an async method
import asyncio
assert asyncio.iscoroutinefunction(tui._start_runtime)
class TestLogoDisplay:
"""Tests for logo display functionality."""
def test_logo_always_shows_when_enabled(self):
"""Test logo shows even when messages exist."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig, ChatMessage
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
# Add a message
tui.messages.append(ChatMessage(role="system", content="Test message"))
# Logo should still be in output
output = tui._format_output()
assert "Praison" in output or "██" in output # Logo contains these
def test_logo_hidden_when_disabled(self):
"""Test logo is hidden when show_logo=False."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(show_logo=False)
tui = AsyncTUI(config=config)
output = tui._format_output()
# Should not contain the large logo characters
assert "██████" not in output
def test_tips_hidden_after_first_message(self):
"""Test tips are hidden after first message but logo remains."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig, ChatMessage
config = AsyncTUIConfig(show_logo=True)
tui = AsyncTUI(config=config)
# Before messages - tips should show
output_before = tui._format_output()
assert "Type your message" in output_before
# Add a message
tui.messages.append(ChatMessage(role="user", content="Hello"))
# After messages - tips should be hidden but logo remains
output_after = tui._format_output()
assert "Type your message" not in output_after
# Logo should still be there
assert "Model:" in output_after
class TestToolCallVisibility:
"""Tests for tool call visibility."""
def test_tool_role_in_format_output(self):
"""Test tool role messages are formatted correctly."""
from praisonai.cli.interactive.async_tui import AsyncTUI, ChatMessage
tui = AsyncTUI()
tui.messages.append(ChatMessage(role="tool", content="read_file completed"))
output = tui._format_output()
assert "⚙" in output
assert "read_file completed" in output
class TestDebugMode:
"""Tests for debug mode (/debug command and --debug flag)."""
def test_debug_command_toggles_mode(self):
"""Test /debug command toggles debug mode."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
assert tui.config.debug is False
result = tui._handle_command("/debug")
assert result is True
assert tui.config.debug is True
assert "enabled" in tui.messages[0].content.lower()
assert "async_tui_debug.log" in tui.messages[0].content
def test_debug_flag_in_config(self):
"""Test debug flag can be set via config."""
from praisonai.cli.interactive.async_tui import AsyncTUI, AsyncTUIConfig
config = AsyncTUIConfig(debug=True)
tui = AsyncTUI(config=config)
assert tui.config.debug is True
class TestPlanningIntegration:
"""Tests for planning integration (/plan command)."""
def test_plan_command_no_args(self):
"""Test /plan command without arguments shows usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/plan")
assert result is True
assert len(tui.messages) == 1
assert "Usage:" in tui.messages[0].content
assert "/plan" in tui.messages[0].content
def test_plan_command_with_task(self):
"""Test /plan command with a task description."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
# Mock _queue_or_execute to prevent actual execution
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command("/plan refactor the auth module")
assert result is True
assert len(tui.messages) == 1
assert "Creating plan" in tui.messages[0].content
assert len(executed_prompts) == 1
assert "refactor the auth module" in executed_prompts[0]
class TestHandoffSubagents:
"""Tests for handoff/subagents (/handoff command)."""
def test_handoff_command_no_args(self):
"""Test /handoff command without arguments shows usage."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/handoff")
assert result is True
assert len(tui.messages) == 1
assert "Usage:" in tui.messages[0].content
assert "code" in tui.messages[0].content
assert "research" in tui.messages[0].content
def test_handoff_command_missing_task(self):
"""Test /handoff command with only agent type."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command("/handoff code")
assert result is True
assert "specify both" in tui.messages[0].content.lower()
def test_handoff_command_code_agent(self):
"""Test /handoff code command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command('/handoff code "fix the bug"')
assert result is True
assert "Handing off" in tui.messages[0].content
assert len(executed_prompts) == 1
assert "code" in executed_prompts[0].lower()
def test_handoff_command_research_agent(self):
"""Test /handoff research command."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
executed_prompts = []
tui._queue_or_execute = lambda p: executed_prompts.append(p)
result = tui._handle_command('/handoff research "find best practices"')
assert result is True
assert len(executed_prompts) == 1
assert "research" in executed_prompts[0].lower()
def test_handoff_command_unknown_agent(self):
"""Test /handoff with unknown agent type."""
from praisonai.cli.interactive.async_tui import AsyncTUI
tui = AsyncTUI()
result = tui._handle_command('/handoff unknown "some task"')
assert result is True
assert "Unknown agent type" in tui.messages[0].content
class TestInteractiveRuntimeReadOnly:
"""Tests for InteractiveRuntime read_only property."""
def test_read_only_false_when_config_approval_auto(self):
"""Test read_only is False when config.approval_mode is 'auto'."""
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
config = RuntimeConfig(approval_mode="auto")
runtime = InteractiveRuntime(config)
# Even without ACP ready, should not be read-only in auto mode
assert runtime.read_only is False
def test_read_only_false_when_env_approval_auto(self):
"""Test read_only is False when PRAISON_APPROVAL_MODE env is 'auto'."""
import os
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
# Set env var
old_val = os.environ.get("PRAISON_APPROVAL_MODE")
os.environ["PRAISON_APPROVAL_MODE"] = "auto"
try:
config = RuntimeConfig(approval_mode="manual") # Config says manual
runtime = InteractiveRuntime(config)
# Config takes precedence, so this should be read-only
# But env var should also work as fallback
# Actually config.approval_mode="manual" so env var is checked
# Wait, config says manual, so it won't return False on first check
# Then env var check should return False
assert runtime.read_only is False
finally:
if old_val is None:
os.environ.pop("PRAISON_APPROVAL_MODE", None)
else:
os.environ["PRAISON_APPROVAL_MODE"] = old_val
def test_read_only_true_when_manual_mode(self):
"""Test read_only respects _read_only flag in manual mode."""
import os
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
# Clear env var
old_val = os.environ.get("PRAISON_APPROVAL_MODE")
os.environ.pop("PRAISON_APPROVAL_MODE", None)
try:
config = RuntimeConfig(approval_mode="manual")
runtime = InteractiveRuntime(config)
runtime._read_only = True # Explicitly set read-only
assert runtime.read_only is True
finally:
if old_val is not None:
os.environ["PRAISON_APPROVAL_MODE"] = old_val
@@ -0,0 +1,117 @@
"""
Tests for the unified branding module.
This module provides the single source of truth for logo/version/branding
across all interactive UIs.
"""
import pytest
class TestBrandingConstants:
"""Tests for branding constants."""
def test_product_name(self):
"""Test PRODUCT_NAME constant."""
from praisonai.cli.branding import PRODUCT_NAME
assert PRODUCT_NAME == "Praison AI"
def test_logo_large_exists(self):
"""Test LOGO_LARGE constant exists and has ASCII art."""
from praisonai.cli.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE
def test_logo_medium_exists(self):
"""Test LOGO_MEDIUM constant exists."""
from praisonai.cli.branding import LOGO_MEDIUM
assert len(LOGO_MEDIUM) > 0
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists and has branding."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
assert "Praison AI" in LOGO_SMALL
def test_logo_minimal_exists(self):
"""Test LOGO_MINIMAL constant exists and has branding."""
from praisonai.cli.branding import LOGO_MINIMAL
assert LOGO_MINIMAL == "Praison AI"
class TestGetLogo:
"""Tests for get_logo function."""
def test_get_logo_wide_terminal(self):
"""Test get_logo returns LOGO_LARGE for wide terminals."""
from praisonai.cli.branding import get_logo, LOGO_LARGE
assert get_logo(100) == LOGO_LARGE
assert get_logo(80) == LOGO_LARGE
assert get_logo(75) == LOGO_LARGE
def test_get_logo_medium_terminal(self):
"""Test get_logo returns LOGO_MEDIUM for medium terminals."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
assert get_logo(74) == LOGO_MEDIUM
assert get_logo(50) == LOGO_MEDIUM
assert get_logo(40) == LOGO_MEDIUM
def test_get_logo_narrow_terminal(self):
"""Test get_logo returns LOGO_SMALL for narrow terminals."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
assert get_logo(39) == LOGO_SMALL
assert get_logo(30) == LOGO_SMALL
assert get_logo(20) == LOGO_SMALL
class TestGetVersion:
"""Tests for get_version function."""
def test_get_version_returns_string(self):
"""Test get_version returns a string."""
from praisonai.cli.branding import get_version
version = get_version()
assert isinstance(version, str)
assert len(version) > 0
def test_get_version_format(self):
"""Test get_version returns a valid version format."""
from praisonai.cli.branding import get_version
version = get_version()
# Should be in format X.Y.Z or similar
parts = version.split(".")
assert len(parts) >= 2
class TestGetBanner:
"""Tests for get_banner function."""
def test_get_banner_includes_logo(self):
"""Test get_banner includes logo."""
from praisonai.cli.branding import get_banner
banner = get_banner(100)
assert "██" in banner or "Praison" in banner
def test_get_banner_includes_version(self):
"""Test get_banner includes version when requested."""
from praisonai.cli.branding import get_banner, get_version
banner = get_banner(100, show_version=True)
version = get_version()
assert version in banner
def test_get_banner_includes_model(self):
"""Test get_banner includes model when provided."""
from praisonai.cli.branding import get_banner
banner = get_banner(100, model="gpt-4o")
assert "gpt-4o" in banner
class TestGetWelcomeTips:
"""Tests for get_welcome_tips function."""
def test_get_welcome_tips_content(self):
"""Test get_welcome_tips returns helpful tips."""
from praisonai.cli.branding import get_welcome_tips
tips = get_welcome_tips()
assert "Enter" in tips or "help" in tips
assert len(tips) > 0
@@ -0,0 +1,406 @@
"""
Tests for PraisonIO - Interactive CLI I/O handler.
"""
from unittest.mock import patch
class TestIOConfig:
"""Tests for IOConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.praison_io import IOConfig
config = IOConfig()
assert config.pretty is True
assert config.user_input_color == "#00cc00"
assert config.multiline_mode is False
assert config.vi_mode is False
assert config.enable_completions is True
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.praison_io import IOConfig
config = IOConfig(
pretty=False,
user_input_color="#ff0000",
multiline_mode=True,
vi_mode=True,
)
assert config.pretty is False
assert config.user_input_color == "#ff0000"
assert config.multiline_mode is True
assert config.vi_mode is True
class TestPraisonCompleter:
"""Tests for PraisonCompleter."""
def test_init_empty(self):
"""Test initialization with no commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
assert completer.commands == {}
assert len(completer.files) == 0
assert len(completer.symbols) == 0
def test_init_with_commands(self):
"""Test initialization with commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
commands = {"help": "Show help", "exit": "Exit"}
completer = PraisonCompleter(commands=commands)
assert completer.commands == commands
def test_add_files(self):
"""Test adding files."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_files(["file1.py", "file2.py"])
assert "file1.py" in completer.files
assert "file2.py" in completer.files
def test_add_symbols(self):
"""Test adding symbols."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_symbols(["MyClass", "my_function"])
assert "MyClass" in completer.symbols
assert "my_function" in completer.symbols
def test_get_completions_slash_commands(self):
"""Test completions for slash commands."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
commands = {"help": "Show help", "history": "Show history", "exit": "Exit"}
completer = PraisonCompleter(commands=commands)
completions = completer.get_completions("/h", 2)
completion_texts = [c[0] for c in completions]
assert "/help" in completion_texts
assert "/history" in completion_texts
assert "/exit" not in completion_texts
def test_get_completions_file_mentions(self):
"""Test completions for @ file mentions."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_files(["main.py", "test.py", "README.md"])
completions = completer.get_completions("@main", 5)
completion_texts = [c[0] for c in completions]
assert "@main.py" in completion_texts
def test_get_completions_symbols(self):
"""Test completions for symbols."""
from praisonai.cli.interactive.praison_io import PraisonCompleter
completer = PraisonCompleter()
completer.add_symbols(["MyClass", "MyFunction", "other"])
completions = completer.get_completions("My", 2)
completion_texts = [c[0] for c in completions]
assert "MyClass" in completion_texts
assert "MyFunction" in completion_texts
assert "other" not in completion_texts
class TestMarkdownStream:
"""Tests for MarkdownStream."""
def test_init(self):
"""Test initialization."""
from praisonai.cli.interactive.praison_io import MarkdownStream
stream = MarkdownStream(live_window=8)
assert stream.live_window == 8
assert stream.printed == []
assert stream.live is None
def test_render_to_lines_fallback(self):
"""Test rendering without Rich."""
from praisonai.cli.interactive.praison_io import MarkdownStream
stream = MarkdownStream()
# This tests the fallback path
text = "Hello\nWorld"
lines = stream._render_to_lines(text)
assert len(lines) >= 1
class TestPraisonIO:
"""Tests for PraisonIO."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
assert io.config is not None
assert io.completer is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
assert io.config.pretty is False
def test_add_commands(self):
"""Test adding commands."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_commands({"test": "Test command"})
assert "test" in io.completer.commands
def test_add_files(self):
"""Test adding files."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_files(["file.py"])
assert "file.py" in io.completer.files
def test_add_symbols(self):
"""Test adding symbols."""
from praisonai.cli.interactive.praison_io import PraisonIO
io = PraisonIO()
io.add_symbols(["MyClass"])
assert "MyClass" in io.completer.symbols
@patch('builtins.print')
def test_rule_no_rich(self, mock_print):
"""Test rule without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.rule()
mock_print.assert_called()
@patch('builtins.print')
def test_tool_output_no_rich(self, mock_print):
"""Test tool output without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.tool_output("Test message")
mock_print.assert_called_with("⚙ Test message")
@patch('builtins.print')
def test_info_no_rich(self, mock_print):
"""Test info without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.info("Test info")
mock_print.assert_called_with("ℹ Test info")
@patch('builtins.print')
def test_success_no_rich(self, mock_print):
"""Test success without Rich."""
from praisonai.cli.interactive.praison_io import PraisonIO, IOConfig
config = IOConfig(pretty=False)
io = PraisonIO(config=config)
io.success("Test success")
mock_print.assert_called_with("✓ Test success")
class TestREPLConfig:
"""Tests for REPLConfig."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.repl import REPLConfig
config = REPLConfig()
assert config.model == "gpt-4o-mini"
assert config.verbose is False
assert config.memory is False
assert config.tools is None
assert config.autonomy is True
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.repl import REPLConfig
config = REPLConfig(
model="gpt-4o",
verbose=True,
memory=True,
)
assert config.model == "gpt-4o"
assert config.verbose is True
assert config.memory is True
class TestInteractiveREPL:
"""Tests for InteractiveREPL."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
assert repl.config is not None
assert repl.io is not None
assert repl._running is False
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.repl import InteractiveREPL, REPLConfig
config = REPLConfig(model="gpt-4o")
repl = InteractiveREPL(config=config)
assert repl.config.model == "gpt-4o"
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._running = True
result = repl._handle_command("/exit")
assert result is True
assert repl._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._running = True
result = repl._handle_command("/quit")
assert result is True
assert repl._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/help")
assert result is True
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._conversation_history = [{"role": "user", "content": "test"}]
result = repl._handle_command("/clear")
assert result is True
assert len(repl._conversation_history) == 0
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/model")
assert result is True
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/model gpt-4o")
assert result is True
assert repl.config.model == "gpt-4o"
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/unknown")
assert result is True # Still handled (shows warning)
def test_handle_command_session(self):
"""Test handling session command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._session_id = "test-session-123"
result = repl._handle_command("/session")
assert result is True
def test_handle_command_cost(self):
"""Test handling cost command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._total_tokens = 100
repl._total_cost = 0.01
result = repl._handle_command("/cost")
assert result is True
def test_handle_command_history_empty(self):
"""Test handling history command with empty history."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
result = repl._handle_command("/history")
assert result is True
def test_handle_command_history_with_items(self):
"""Test handling history command with items."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
repl._conversation_history = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
result = repl._handle_command("/history")
assert result is True
def test_handle_command_compact(self):
"""Test handling compact command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
original = repl.io.config.pretty
result = repl._handle_command("/compact")
assert result is True
assert repl.io.config.pretty != original
def test_handle_command_multiline(self):
"""Test handling multiline command."""
from praisonai.cli.interactive.repl import InteractiveREPL
repl = InteractiveREPL()
original = repl.io.config.multiline_mode
result = repl._handle_command("/multiline")
assert result is True
assert repl.io.config.multiline_mode != original
class TestStartInteractive:
"""Tests for start_interactive function."""
def test_function_exists(self):
"""Test that start_interactive function exists."""
from praisonai.cli.interactive.repl import start_interactive
assert callable(start_interactive)
@@ -0,0 +1,282 @@
"""
Tests for PraisonAI Split-Pane TUI Application.
"""
class TestSplitTUIConfig:
"""Tests for SplitTUIConfig dataclass."""
def test_default_values(self):
"""Test default configuration values."""
from praisonai.cli.interactive.split_tui import SplitTUIConfig
config = SplitTUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_status_bar is True
assert config.session_id is None
assert config.workspace is None
def test_custom_values(self):
"""Test custom configuration values."""
from praisonai.cli.interactive.split_tui import SplitTUIConfig
config = SplitTUIConfig(
model="gpt-4o",
show_logo=False,
session_id="test123",
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.session_id == "test123"
class TestChatMessage:
"""Tests for ChatMessage dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.split_tui import ChatMessage
msg = ChatMessage(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
def test_message_roles(self):
"""Test different message roles."""
from praisonai.cli.interactive.split_tui import ChatMessage
roles = ["user", "assistant", "system", "status"]
for role in roles:
msg = ChatMessage(role=role, content="test")
assert msg.role == role
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(50)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
def test_logo_contains_praison_ai(self):
"""Test that logo contains 'Praison AI' branding."""
from praisonai.cli.branding import LOGO_SMALL
assert "Praison AI" in LOGO_SMALL
class TestSplitTUI:
"""Tests for SplitTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.split_tui import SplitTUI, SplitTUIConfig
config = SplitTUIConfig(model="gpt-4o", show_logo=False)
tui = SplitTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_format_output_empty(self):
"""Test format_output with no messages."""
from praisonai.cli.interactive.split_tui import SplitTUI, SplitTUIConfig
config = SplitTUIConfig(show_logo=True)
tui = SplitTUI(config=config)
output = tui._format_output()
# Should contain welcome message
assert "Type your message" in output or "gpt-4o-mini" in output
def test_format_output_with_messages(self):
"""Test format_output with messages."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [
ChatMessage(role="user", content="Hello"),
ChatMessage(role="assistant", content="Hi there"),
]
output = tui._format_output()
assert "Hello" in output
assert "Hi there" in output
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/help")
assert result is True
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
assert "/help" in tui.messages[0].content
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
assert len(tui.messages) == 0
def test_handle_command_new(self):
"""Test handling new command."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
old_session = tui.session_id
tui.messages = [ChatMessage(role="user", content="test")]
result = tui._handle_command("/new")
assert result is True
assert len(tui.messages) == 1 # New session message
assert tui.session_id != old_session
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
assert tui._agent is None # Agent should be reset
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.split_tui import SplitTUI
tui = SplitTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartSplitTUI:
"""Tests for start_split_tui function."""
def test_function_exists(self):
"""Test that start_split_tui function exists."""
from praisonai.cli.interactive.split_tui import start_split_tui
assert callable(start_split_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_exists(self):
"""Test LOGO constant exists and has content."""
from praisonai.cli.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "██" in LOGO_LARGE # Contains block characters
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_minimal_branding(self):
"""Test LOGO_MINIMAL has correct branding."""
from praisonai.cli.branding import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
class TestMessageFormatting:
"""Tests for message formatting."""
def test_user_message_prefix(self):
"""Test user messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="user", content="test")]
output = tui._format_output()
assert "› test" in output
def test_assistant_message_prefix(self):
"""Test assistant messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="assistant", content="response")]
output = tui._format_output()
assert "● response" in output
def test_status_message_prefix(self):
"""Test status messages have correct prefix."""
from praisonai.cli.interactive.split_tui import SplitTUI, ChatMessage
tui = SplitTUI()
tui.messages = [ChatMessage(role="status", content="thinking")]
output = tui._format_output()
assert "⏳ thinking" in output
@@ -0,0 +1,344 @@
"""
Tests for PraisonAI TUI Application.
"""
class TestColorScheme:
"""Tests for ColorScheme dataclass."""
def test_default_values(self):
"""Test default color scheme values."""
from praisonai.cli.interactive.tui_app import ColorScheme
colors = ColorScheme()
assert colors.primary == "#00aaff"
assert colors.secondary == "#888888"
assert colors.accent == "#00cc00"
assert colors.text == "#ffffff"
assert colors.success == "#00cc00"
assert colors.error == "#ff4444"
class TestTUIConfig:
"""Tests for TUIConfig dataclass."""
def test_default_values(self):
"""Test default TUI configuration values."""
from praisonai.cli.interactive.tui_app import TUIConfig
config = TUIConfig()
assert config.model == "gpt-4o-mini"
assert config.show_logo is True
assert config.show_tips is True
assert config.show_status_bar is True
assert config.compact_mode is False
assert config.multiline is False
assert config.vi_mode is False
def test_custom_values(self):
"""Test custom TUI configuration values."""
from praisonai.cli.interactive.tui_app import TUIConfig
config = TUIConfig(
model="gpt-4o",
show_logo=False,
compact_mode=True,
)
assert config.model == "gpt-4o"
assert config.show_logo is False
assert config.compact_mode is True
class TestMessage:
"""Tests for Message dataclass."""
def test_message_creation(self):
"""Test message creation."""
from praisonai.cli.interactive.tui_app import Message
msg = Message(role="user", content="Hello")
assert msg.role == "user"
assert msg.content == "Hello"
assert msg.timestamp is not None
assert msg.metadata == {}
def test_message_with_metadata(self):
"""Test message with metadata."""
from praisonai.cli.interactive.tui_app import Message
msg = Message(role="assistant", content="Hi", metadata={"tokens": 10})
assert msg.metadata == {"tokens": 10}
class TestGetLogo:
"""Tests for get_logo function."""
def test_logo_wide_terminal(self):
"""Test logo for wide terminal."""
from praisonai.cli.branding import get_logo, LOGO_LARGE
logo = get_logo(100)
assert logo == LOGO_LARGE
def test_logo_medium_terminal(self):
"""Test logo for medium terminal."""
from praisonai.cli.branding import get_logo, LOGO_MEDIUM
logo = get_logo(65)
assert logo == LOGO_MEDIUM
def test_logo_narrow_terminal(self):
"""Test logo for narrow terminal."""
from praisonai.cli.branding import get_logo, LOGO_SMALL
logo = get_logo(30)
assert logo == LOGO_SMALL
class TestPraisonTUI:
"""Tests for PraisonTUI class."""
def test_init_default(self):
"""Test default initialization."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
assert tui.config is not None
assert tui.messages == []
assert tui._running is False
assert tui.session_id is not None
def test_init_with_config(self):
"""Test initialization with config."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(model="gpt-4o", show_logo=False)
tui = PraisonTUI(config=config)
assert tui.config.model == "gpt-4o"
assert tui.config.show_logo is False
def test_render_logo_disabled(self):
"""Test render_logo when disabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=False)
tui = PraisonTUI(config=config)
logo = tui._render_logo()
assert logo == ""
def test_render_logo_enabled(self):
"""Test render_logo when enabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=True, model="gpt-4o-mini")
tui = PraisonTUI(config=config)
logo = tui._render_logo()
assert "PRAISON" in logo or "gpt-4o-mini" in logo
def test_render_welcome(self):
"""Test render_welcome."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_logo=True, show_tips=True)
tui = PraisonTUI(config=config)
welcome = tui._render_welcome()
assert "Type your message" in welcome or len(welcome) > 0
def test_render_status_bar(self):
"""Test render_status_bar."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_status_bar=True, model="gpt-4o")
tui = PraisonTUI(config=config)
status = tui._render_status_bar()
assert "gpt-4o" in status
def test_render_status_bar_disabled(self):
"""Test render_status_bar when disabled."""
from praisonai.cli.interactive.tui_app import PraisonTUI, TUIConfig
config = TUIConfig(show_status_bar=False)
tui = PraisonTUI(config=config)
status = tui._render_status_bar()
assert status == ""
def test_format_message_user(self):
"""Test format_message for user role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="user", content="Hello")
formatted = tui._format_message(msg)
assert "Hello" in formatted
assert "›" in formatted
def test_format_message_assistant(self):
"""Test format_message for assistant role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="assistant", content="Hi there")
formatted = tui._format_message(msg)
assert "Hi there" in formatted
assert "●" in formatted
def test_format_message_tool(self):
"""Test format_message for tool role."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
msg = Message(role="tool", content="Running command")
formatted = tui._format_message(msg)
assert "Running command" in formatted
assert "⚙" in formatted
def test_handle_command_exit(self):
"""Test handling exit command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._running = True
result = tui._handle_command("/exit")
assert result is True
assert tui._running is False
def test_handle_command_quit(self):
"""Test handling quit command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._running = True
result = tui._handle_command("/quit")
assert result is True
assert tui._running is False
def test_handle_command_help(self):
"""Test handling help command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/help")
assert result is True
# Help should add a system message
assert len(tui.messages) == 1
assert tui.messages[0].role == "system"
def test_handle_command_clear(self):
"""Test handling clear command."""
from praisonai.cli.interactive.tui_app import PraisonTUI, Message
tui = PraisonTUI()
tui.messages = [Message(role="user", content="test")]
result = tui._handle_command("/clear")
assert result is True
assert len(tui.messages) == 0
def test_handle_command_model_show(self):
"""Test handling model command (show)."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/model")
assert result is True
assert len(tui.messages) == 1
assert "gpt-4o-mini" in tui.messages[0].content
def test_handle_command_model_change(self):
"""Test handling model command (change)."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/model gpt-4o")
assert result is True
assert tui.config.model == "gpt-4o"
def test_handle_command_cost(self):
"""Test handling cost command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
tui._total_tokens = 100
tui._total_cost = 0.01
result = tui._handle_command("/cost")
assert result is True
assert len(tui.messages) == 1
assert "100" in tui.messages[0].content
def test_handle_command_compact(self):
"""Test handling compact command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
original = tui.config.compact_mode
result = tui._handle_command("/compact")
assert result is True
assert tui.config.compact_mode != original
def test_handle_command_multiline(self):
"""Test handling multiline command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
original = tui.config.multiline
result = tui._handle_command("/multiline")
assert result is True
assert tui.config.multiline != original
def test_handle_command_unknown(self):
"""Test handling unknown command."""
from praisonai.cli.interactive.tui_app import PraisonTUI
tui = PraisonTUI()
result = tui._handle_command("/unknown")
assert result is True
assert len(tui.messages) == 1
assert "Unknown" in tui.messages[0].content
class TestStartTUI:
"""Tests for start_tui function."""
def test_function_exists(self):
"""Test that start_tui function exists."""
from praisonai.cli.interactive.tui_app import start_tui
assert callable(start_tui)
class TestASCIILogos:
"""Tests for ASCII logo constants."""
def test_logo_large_exists(self):
"""Test LOGO_LARGE constant exists."""
from praisonai.cli.branding import LOGO_LARGE
assert len(LOGO_LARGE) > 0
assert "PRAISON" in LOGO_LARGE or "██" in LOGO_LARGE
def test_logo_small_exists(self):
"""Test LOGO_SMALL constant exists."""
from praisonai.cli.branding import LOGO_SMALL
assert len(LOGO_SMALL) > 0
def test_logo_tiny_exists(self):
"""Test LOGO_MEDIUM constant exists (was LOGO_TINY)."""
from praisonai.cli.branding import LOGO_MEDIUM
assert len(LOGO_MEDIUM) > 0
def test_logo_minimal_exists(self):
"""Test LOGO_MINIMAL constant exists."""
from praisonai.cli.branding import LOGO_MINIMAL
assert "Praison AI" in LOGO_MINIMAL
+4 -4
View File
@@ -4276,7 +4276,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=0.11.2" },
{ name = "praisonaiagents", specifier = ">=0.11.5" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4324,7 +4324,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.2"
version = "0.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4333,9 +4333,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/d0/84583da326312957eb887e32d6df4549889494b736b2f3925f3b80fc7e70/praisonaiagents-0.11.2.tar.gz", hash = "sha256:a32e1063404d61cac71be4ee447f6888f7b9566a298ac63793aeaec41eeeeab1", size = 972927, upload-time = "2026-01-10T19:35:37.032Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/aa/baf52e0cfc9ee9eaed2dcb1da8eb96a7ebb7f2678f4bc2ceeb14b590cbe6/praisonaiagents-0.11.5.tar.gz", hash = "sha256:c18635a5ff4b6feaf07da5d8944d0e7b7c44a4d761b6c432e0d6f6217b3ade67", size = 981281, upload-time = "2026-01-12T09:03:11.404Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e8/3c13e6f5a3bcd4b01c0a373a2d3fdd797eeea3f46f673fc6a14ba081e1f2/praisonaiagents-0.11.2-py3-none-any.whl", hash = "sha256:e5d666d23d59e53b498beefc30edef3d28ca42fda7b0cd862d1859e59410067c", size = 954207, upload-time = "2026-01-10T19:35:35.034Z" },
{ url = "https://files.pythonhosted.org/packages/6d/bb/7220e1dde64fb471a220d3767ef845d93af6849efff32bb43dd0aed973e3/praisonaiagents-0.11.5-py3-none-any.whl", hash = "sha256:c207c3db8274f35981fc8ec454d747d098a6f25939e44f8f4041766f0fe35931", size = 964203, upload-time = "2026-01-12T09:03:09.11Z" },
]
[[package]]