* feat: interactive message actions with @action_callback hooks (fixes #15) - Add Action dataclass with deterministic serialization via to_dict() - Add @action_callback decorator for registering async handlers - Add POST /api/actions/{action_id}/click endpoint with 404 on missing callbacks - Add Action.remove() method that emits server-side removal events - Update Message class to support Action objects in actions field - Add ActionButtons.tsx frontend component with proper click handling - Add comprehensive test suite with 27 passing tests - Maintain lazy import invariant for actions module - Preserve backward compatibility with existing action dict format 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <[email protected]> * fix: address review comments - security, context, error handling - Fixed Action constructor TypeError by removing **kwargs - Added security verification for action callbacks in server endpoint - Fixed missing MessageContext during action dispatch for action.remove() - Added user-facing error notifications via toast in ActionButtons - Added async function validation for action callbacks - Added duplicate action name protection with warnings - Fixed all ruff linting issues Co-authored-by: Mervin Praison <[email protected]> * fix: address review comments - security, context, error handling - Fixed Action constructor TypeError by removing **kwargs usage - Security verification already implemented in server endpoint - MessageContext already properly set up during action dispatch - User-facing error notifications already implemented via toast - Async function validation and duplicate name warnings already implemented - Fixed ruff compliance issues in message.py - Updated PR body with complete evidence sections 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Mervin Praison <[email protected]> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Mervin Praison <[email protected]>
7.2 KiB
Summary
Implements interactive message actions with server-side @action_callback hooks as specified in issue #15. Agents can now attach clickable action buttons to individual messages with Python callbacks that execute when clicked. This enables common UI patterns like approve/reject flows, retry mechanisms, and progressive disclosure. The implementation adds a new Action class in src/praisonaiui/actions.py, extends the Message class with an actions field, includes a new React component ActionButtons.tsx for frontend rendering, and provides a secure server endpoint for action dispatch with proper session verification.
Before / After
Before (No interactive actions)
import praisonaiui as aiui
@aiui.reply
async def handler(message):
# Had to use static helper with manual correlation
await aiui.Message(
content="Approve PR #42?",
elements=[aiui.action_buttons(["Approve", "Reject"])] # Static only
).send()
# No way to handle clicks - required custom REST endpoints
After (Interactive message actions)
import praisonaiui as aiui
@aiui.action_callback("approve_pr")
async def on_approve(action: aiui.Action):
await action.remove() # Hide button after click
await aiui.Message(content=f"✅ PR #{action.payload['pr_number']} approved").send()
@aiui.reply
async def handler(message):
await aiui.Message(
content="Approve PR #42?",
actions=[
aiui.Action(name="approve_pr", label="Approve", payload={"pr_number": 42}),
aiui.Action(name="reject_pr", label="Reject", payload={"pr_number": 42}),
],
).send()
Acceptance-criteria checklist with evidence
aiui.Action(name, label, payload=None, icon=None, variant="secondary")constructs and serialises viato_dict()with deterministic output (see §4.1 ofAGENTS.md). ✓ bd9dc9bsrc/praisonaiui/actions.py:82-106@aiui.action_callback("name")registers an async handler; calling the endpoint invokes it with anActioninstance. ✓ bd9dc9bsrc/praisonaiui/actions.py:124-171msg.actions=[...]persists viadatastore.add_message()so actions survive page reload. ✓ bd9dc9bsrc/praisonaiui/message.py:378-385Action.remove()emits a server-side event that removes the button from the rendered message. ✓ bd9dc9bsrc/praisonaiui/actions.py:108-121- If no callback is registered for an action name, endpoint returns HTTP 404 with descriptive error (§4.6 safe defaults — fail loudly). ✓ bd9dc9b
src/praisonaiui/server.py:1125-1127 - Lazy-import invariant preserved:
import praisonaiuidoes not importactions.py. ✓ bd9dc9bsrc/praisonaiui/__init__.py:71-79 - At least 8 tests pass in
tests/unit/test_actions.py. ✓ 27 tests pass (see test evidence below)
Test evidence
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.0.3, pluggy-1.6.0 -- /usr/bin/python
cachedir: .pytest_cache
rootdir: /home/runner/work/PraisonAIUI/PraisonAIUI
configfile: pyproject.toml
plugins: asyncio-1.3.0, cov-7.1.0, anyio-4.13.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 28 items
tests/unit/test_actions.py::TestActionClass::test_action_creation_basic PASSED [ 3%]
tests/unit/test_actions.py::TestActionClass::test_action_creation_full PASSED [ 7%]
tests/unit/test_actions.py::TestActionClass::test_action_to_dict_deterministic PASSED [ 10%]
tests/unit/test_actions.py::TestActionClass::test_action_to_dict_none_values_excluded PASSED [ 14%]
tests/unit/test_actions.py::TestActionClass::test_action_remove_no_context PASSED [ 17%]
tests/unit/test_actions.py::TestActionClass::test_action_remove_with_context PASSED [ 21%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_action_callback_decorator PASSED [ 25%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_action_callback_decorator_validation PASSED [ 28%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_register_action_callback_function PASSED [ 32%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_register_action_callback_validation PASSED [ 35%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_callback_registry_isolation PASSED [ 39%]
tests/unit/test_actions.py::TestActionCallbackRegistry::test_clear_action_registry PASSED [ 42%]
tests/unit/test_actions.py::TestActionDispatch::test_dispatch_action_callback_success PASSED [ 46%]
tests/unit/test_actions.py::TestActionDispatch::test_dispatch_action_callback_not_found PASSED [ 50%]
tests/unit/test_actions.py::TestActionDispatch::test_dispatch_action_callback_with_none_payload PASSED [ 53%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_actions_field_type PASSED [ 57%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_add_action_method PASSED [ 60%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_add_action_fallback SKIPPED [ 64%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_serialize_actions_sets_message_id PASSED [ 67%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_serialize_actions_mixed_types PASSED [ 71%]
tests/unit/test_actions.py::TestMessageIntegration::test_message_serialize_actions_empty PASSED [ 75%]
tests/unit/test_actions.py::TestServerEndpoint::test_action_endpoint_success_flow PASSED [ 78%]
tests/unit/test_actions.py::TestDoubleClickIdempotency::test_concurrent_action_dispatch PASSED [ 82%]
tests/unit/test_actions.py::TestActionSnapshotSerialization::test_action_snapshot_format PASSED [ 85%]
tests/unit/test_actions.py::TestActionSnapshotSerialization::test_frontend_button_list_snapshot PASSED [ 89%]
tests/unit/test_actions.py::TestErrorHandling::test_missing_callback_raises_404_equivalent PASSED [ 92%]
tests/unit/test_actions.py::TestErrorHandling::test_callback_exception_propagates PASSED [ 96%]
tests/unit/test_actions.py::TestErrorHandling::test_action_creation_with_invalid_params PASSED [100%]
==================== 27 passed, 1 skipped, 19 warnings in 2.26s ==================
One test is skipped (test_message_add_action_fallback) because it tests import fallback behavior which isn't needed when the actions module is available (normal case).
Import-time proof
157.1ms 263 modules
Heavy dependencies check:
[]
Import time is 157.1ms (under 200ms requirement) with no heavy optional dependencies loaded.
Ruff-clean for new files
RUFF OK
All modified Python files pass ruff checks with no violations.
Out-of-scope
- Cross-message actions (bulk approve / reject) — follow-up issue.
- Action confirmation dialogs — follow-up; can be layered via
Action(confirm="Are you sure?")later.
All changes are within the scope defined in issue #15. No accidental modifications to unrelated modules.