286 lines
10 KiB
Python
286 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from asgiref.sync import async_to_sync
|
|
from django.test import TestCase
|
|
|
|
from core.commands.base import CommandContext
|
|
from core.commands.engine import process_inbound_message
|
|
from core.commands.handlers.claude import parse_claude_command
|
|
from core.models import (
|
|
ChatSession,
|
|
CodexPermissionRequest,
|
|
CodexRun,
|
|
CommandChannelBinding,
|
|
CommandProfile,
|
|
DerivedTask,
|
|
ExternalSyncEvent,
|
|
Message,
|
|
Person,
|
|
PersonIdentifier,
|
|
TaskProject,
|
|
TaskProviderConfig,
|
|
User,
|
|
)
|
|
|
|
|
|
class ClaudeCommandParserTests(TestCase):
|
|
def test_parse_variants(self):
|
|
self.assertEqual("default", parse_claude_command("#claude# run this").command)
|
|
self.assertEqual("plan", parse_claude_command("#claude plan# run this").command)
|
|
self.assertEqual("status", parse_claude_command("#claude status#").command)
|
|
parsed = parse_claude_command("#claude approve abc123#")
|
|
self.assertEqual("approve", parsed.command)
|
|
self.assertEqual("abc123", parsed.approval_key)
|
|
self.assertEqual("default", parse_claude_command(".claude run this").command)
|
|
self.assertEqual("plan", parse_claude_command(".CLAUDE plan run this").command)
|
|
self.assertEqual("status", parse_claude_command(".claude status").command)
|
|
parsed_dot = parse_claude_command(".claude approve abc123")
|
|
self.assertEqual("approve", parsed_dot.command)
|
|
self.assertEqual("abc123", parsed_dot.approval_key)
|
|
|
|
def test_no_match_returns_none_command(self):
|
|
self.assertIsNone(parse_claude_command("hello world").command)
|
|
self.assertIsNone(parse_claude_command(".codex do this").command)
|
|
|
|
|
|
class ClaudeCommandExecutionTests(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(
|
|
"claude-cmd-user", "claude-cmd@example.com", "x"
|
|
)
|
|
self.person = Person.objects.create(user=self.user, name="Claude Cmd")
|
|
self.identifier = PersonIdentifier.objects.create(
|
|
user=self.user,
|
|
person=self.person,
|
|
service="web",
|
|
identifier="web-chan-1",
|
|
)
|
|
self.session = ChatSession.objects.create(
|
|
user=self.user, identifier=self.identifier
|
|
)
|
|
self.project = TaskProject.objects.create(user=self.user, name="Project A")
|
|
self.task = DerivedTask.objects.create(
|
|
user=self.user,
|
|
project=self.project,
|
|
epic=None,
|
|
title="Task A",
|
|
source_service="web",
|
|
source_channel="web-chan-1",
|
|
reference_code="1",
|
|
status_snapshot="open",
|
|
)
|
|
self.profile = CommandProfile.objects.create(
|
|
user=self.user,
|
|
slug="claude",
|
|
name="Claude",
|
|
enabled=True,
|
|
trigger_token="#claude#",
|
|
reply_required=False,
|
|
exact_match_only=False,
|
|
)
|
|
CommandChannelBinding.objects.create(
|
|
profile=self.profile,
|
|
direction="ingress",
|
|
service="web",
|
|
channel_identifier="web-chan-1",
|
|
enabled=True,
|
|
)
|
|
TaskProviderConfig.objects.create(
|
|
user=self.user,
|
|
provider="claude_cli",
|
|
enabled=True,
|
|
settings={
|
|
"command": "claude",
|
|
"workspace_root": "",
|
|
"default_profile": "",
|
|
"timeout_seconds": 60,
|
|
"chat_link_mode": "task-sync",
|
|
"instance_label": "default",
|
|
"approver_mode": "channel",
|
|
"approver_service": "web",
|
|
"approver_identifier": "approver-chan",
|
|
},
|
|
)
|
|
|
|
def _msg(self, text: str, *, source_chat_id: str = "web-chan-1", reply_to=None):
|
|
return Message.objects.create(
|
|
user=self.user,
|
|
session=self.session,
|
|
sender_uuid="",
|
|
text=text,
|
|
ts=1000 + Message.objects.filter(user=self.user).count(),
|
|
source_service="web",
|
|
source_chat_id=source_chat_id,
|
|
reply_to=reply_to,
|
|
message_meta={},
|
|
)
|
|
|
|
def test_default_submission_creates_run_and_event(self):
|
|
trigger = self._msg("#claude# please update #1")
|
|
results = async_to_sync(process_inbound_message)(
|
|
CommandContext(
|
|
service="web",
|
|
channel_identifier="web-chan-1",
|
|
message_id=str(trigger.id),
|
|
user_id=self.user.id,
|
|
message_text=str(trigger.text),
|
|
payload={},
|
|
)
|
|
)
|
|
self.assertEqual(1, len(results))
|
|
self.assertTrue(results[0].ok)
|
|
run = CodexRun.objects.order_by("-created_at").first()
|
|
self.assertIsNotNone(run)
|
|
self.assertEqual("waiting_approval", run.status)
|
|
event = ExternalSyncEvent.objects.order_by("-created_at").first()
|
|
self.assertEqual("waiting_approval", event.status)
|
|
self.assertEqual(
|
|
"default",
|
|
str((event.payload or {}).get("provider_payload", {}).get("mode") or ""),
|
|
)
|
|
self.assertTrue(
|
|
CodexPermissionRequest.objects.filter(
|
|
user=self.user,
|
|
codex_run=run,
|
|
status="pending",
|
|
).exists()
|
|
)
|
|
# The approval notification must reference ".claude approve" not ".codex approve"
|
|
req = CodexPermissionRequest.objects.get(codex_run=run, status="pending")
|
|
approval_key = str(req.approval_key or "")
|
|
# The approval_key should exist
|
|
self.assertTrue(bool(approval_key))
|
|
|
|
def test_plan_requires_reply_anchor(self):
|
|
trigger = self._msg("#claude plan# #1")
|
|
results = async_to_sync(process_inbound_message)(
|
|
CommandContext(
|
|
service="web",
|
|
channel_identifier="web-chan-1",
|
|
message_id=str(trigger.id),
|
|
user_id=self.user.id,
|
|
message_text=str(trigger.text),
|
|
payload={},
|
|
)
|
|
)
|
|
self.assertEqual(1, len(results))
|
|
self.assertFalse(results[0].ok)
|
|
self.assertEqual("reply_required_for_claude_plan", results[0].error)
|
|
|
|
def test_approve_command_queues_resume_event(self):
|
|
waiting_event = ExternalSyncEvent.objects.create(
|
|
user=self.user,
|
|
task=self.task,
|
|
provider="claude_cli",
|
|
status="waiting_approval",
|
|
payload={},
|
|
error="",
|
|
)
|
|
run = CodexRun.objects.create(
|
|
user=self.user,
|
|
task=self.task,
|
|
project=self.project,
|
|
source_service="web",
|
|
source_channel="web-chan-1",
|
|
status="waiting_approval",
|
|
request_payload={
|
|
"action": "append_update",
|
|
"provider_payload": {"task_id": str(self.task.id)},
|
|
},
|
|
result_payload={},
|
|
)
|
|
CodexPermissionRequest.objects.create(
|
|
user=self.user,
|
|
codex_run=run,
|
|
external_sync_event=waiting_event,
|
|
approval_key="cl-ak-123",
|
|
summary="Need approval",
|
|
requested_permissions={"items": ["write"]},
|
|
resume_payload={"resume": True},
|
|
status="pending",
|
|
)
|
|
CommandChannelBinding.objects.create(
|
|
profile=self.profile,
|
|
direction="ingress",
|
|
service="web",
|
|
channel_identifier="approver-chan",
|
|
enabled=True,
|
|
)
|
|
trigger = self._msg(
|
|
"#claude approve cl-ak-123#", source_chat_id="approver-chan"
|
|
)
|
|
results = async_to_sync(process_inbound_message)(
|
|
CommandContext(
|
|
service="web",
|
|
channel_identifier="approver-chan",
|
|
message_id=str(trigger.id),
|
|
user_id=self.user.id,
|
|
message_text=str(trigger.text),
|
|
payload={},
|
|
)
|
|
)
|
|
self.assertEqual(1, len(results))
|
|
self.assertTrue(results[0].ok)
|
|
run.refresh_from_db()
|
|
waiting_event.refresh_from_db()
|
|
self.assertEqual("approved_waiting_resume", run.status)
|
|
self.assertEqual("ok", waiting_event.status)
|
|
self.assertTrue(
|
|
ExternalSyncEvent.objects.filter(
|
|
idempotency_key="claude_approval:cl-ak-123:approved",
|
|
status="pending",
|
|
).exists()
|
|
)
|
|
|
|
def test_deny_command_marks_run_denied(self):
|
|
waiting_event = ExternalSyncEvent.objects.create(
|
|
user=self.user,
|
|
task=self.task,
|
|
provider="claude_cli",
|
|
status="waiting_approval",
|
|
payload={},
|
|
error="",
|
|
)
|
|
run = CodexRun.objects.create(
|
|
user=self.user,
|
|
task=self.task,
|
|
project=self.project,
|
|
source_service="web",
|
|
source_channel="web-chan-1",
|
|
status="waiting_approval",
|
|
request_payload={},
|
|
result_payload={},
|
|
)
|
|
CodexPermissionRequest.objects.create(
|
|
user=self.user,
|
|
codex_run=run,
|
|
external_sync_event=waiting_event,
|
|
approval_key="cl-deny-1",
|
|
summary="Need approval",
|
|
requested_permissions={"items": ["write"]},
|
|
resume_payload={},
|
|
status="pending",
|
|
)
|
|
CommandChannelBinding.objects.get_or_create(
|
|
profile=self.profile,
|
|
direction="ingress",
|
|
service="web",
|
|
channel_identifier="approver-chan",
|
|
defaults={"enabled": True},
|
|
)
|
|
trigger = self._msg(".claude deny cl-deny-1", source_chat_id="approver-chan")
|
|
results = async_to_sync(process_inbound_message)(
|
|
CommandContext(
|
|
service="web",
|
|
channel_identifier="approver-chan",
|
|
message_id=str(trigger.id),
|
|
user_id=self.user.id,
|
|
message_text=str(trigger.text),
|
|
payload={},
|
|
)
|
|
)
|
|
self.assertEqual(1, len(results))
|
|
self.assertTrue(results[0].ok)
|
|
run.refresh_from_db()
|
|
self.assertEqual("denied", run.status)
|