Files
GIA/core/tests/test_claude_commands_phase1.py

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)