Implement 3 plans

This commit is contained in:
2026-03-06 19:38:32 +00:00
parent 49aaed5dec
commit ff66bc9e1f
13 changed files with 1650 additions and 74 deletions

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from subprocess import CompletedProcess, TimeoutExpired
from unittest.mock import patch
from django.test import SimpleTestCase
from core.tasks.providers.claude_cli import ClaudeCLITaskProvider
class ClaudeCLITaskProviderTests(SimpleTestCase):
def setUp(self):
self.provider = ClaudeCLITaskProvider()
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_healthcheck_success(self, run_mock):
run_mock.return_value = CompletedProcess(
args=["claude", "--version"],
returncode=0,
stdout="claude 1.0.0\n",
stderr="",
)
result = self.provider.healthcheck({"command": "claude", "timeout_seconds": 5})
self.assertTrue(result.ok)
self.assertIn("claude", str(result.payload.get("stdout") or ""))
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_create_task_builds_task_sync_command(self, run_mock):
run_mock.return_value = CompletedProcess(
args=[],
returncode=0,
stdout='{"external_key":"cl-123"}',
stderr="",
)
result = self.provider.create_task(
{
"command": "claude",
"workspace_root": "/tmp/work",
"default_profile": "default",
"timeout_seconds": 30,
},
{
"task_id": "t1",
"title": "hello",
"reference_code": "42",
},
)
self.assertTrue(result.ok)
self.assertEqual("cl-123", result.external_key)
args = run_mock.call_args.args[0]
self.assertEqual(["claude", "task-sync", "--op", "create"], args[:4])
self.assertIn("--workspace", args)
self.assertIn("--payload-json", args)
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_timeout_maps_to_failed_result(self, run_mock):
run_mock.side_effect = TimeoutExpired(cmd=["claude"], timeout=10)
result = self.provider.append_update({"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"})
self.assertFalse(result.ok)
self.assertIn("timeout", result.error)
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_requires_approval_parsed_from_stdout(self, run_mock):
run_mock.return_value = CompletedProcess(
args=[],
returncode=0,
stdout='{"status":"requires_approval","approval_key":"ak-1","permission_request":{"requested_permissions":["write"]}}',
stderr="",
)
result = self.provider.append_update({"command": "claude"}, {"task_id": "t1"})
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status"))
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
run_mock.side_effect = [
CompletedProcess(
args=[],
returncode=2,
stdout="",
stderr="error: unexpected argument '--op' found",
),
CompletedProcess(
args=[],
returncode=0,
stdout='{"status":"ok","external_key":"cl-42"}',
stderr="",
),
]
result = self.provider.create_task({"command": "claude"}, {"task_id": "t1"})
self.assertTrue(result.ok)
self.assertEqual("cl-42", result.external_key)
self.assertEqual(2, run_mock.call_count)
first = run_mock.call_args_list[0].args[0]
second = run_mock.call_args_list[1].args[0]
self.assertIn("--op", first)
self.assertNotIn("--op", second)
self.assertEqual(["claude", "task-sync", "create"], second[:3])
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(self, run_mock):
run_mock.side_effect = [
CompletedProcess(
args=[],
returncode=2,
stdout="",
stderr="error: unexpected argument '--op' found",
),
CompletedProcess(
args=[],
returncode=2,
stdout="",
stderr="error: unrecognized subcommand 'create'\nUsage: claude [OPTIONS] [PROMPT]",
),
]
result = self.provider.create_task(
{"command": "claude"},
{
"task_id": "t1",
"trigger_message_id": "m1",
"mode": "default",
},
)
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", str((result.payload or {}).get("status") or ""))
self.assertEqual("builtin_task_sync_stub", str((result.payload or {}).get("fallback_mode") or ""))
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_builtin_stub_approval_response_returns_ok(self, run_mock):
run_mock.side_effect = [
CompletedProcess(
args=[],
returncode=2,
stdout="",
stderr="error: unexpected argument '--op' found",
),
CompletedProcess(
args=[],
returncode=2,
stdout="",
stderr="error: unexpected argument 'append_update' found\nUsage: claude [OPTIONS] [PROMPT]",
),
]
result = self.provider.append_update(
{"command": "claude"},
{
"task_id": "t1",
"mode": "approval_response",
"approval_key": "abc123",
},
)
self.assertTrue(result.ok)
self.assertFalse(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("ok", str((result.payload or {}).get("status") or ""))
def test_provider_name_and_run_in_worker(self):
self.assertEqual("claude_cli", self.provider.name)
self.assertTrue(self.provider.run_in_worker)
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_healthcheck_failure(self, run_mock):
run_mock.return_value = CompletedProcess(
args=["claude", "--version"],
returncode=1,
stdout="",
stderr="command not found: claude",
)
result = self.provider.healthcheck({"command": "claude"})
self.assertFalse(result.ok)
self.assertIn("command not found", result.error)

View File

@@ -0,0 +1,279 @@
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,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest,
CodexRun,
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)

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import datetime
from unittest.mock import AsyncMock, patch
from asgiref.sync import async_to_sync
from django.test import TestCase, override_settings
from django.utils import timezone
from core.models import (
ChatSession,
ChatTaskSource,
DerivedTask,
DerivedTaskEvent,
Message,
Person,
PersonIdentifier,
TaskProject,
User,
)
from core.tasks.engine import (
_parse_assignee,
_parse_due_date,
process_inbound_task_intelligence,
)
class DueDateParsingTests(TestCase):
def test_parses_due_iso_date(self):
result = _parse_due_date("due 2026-04-15")
self.assertEqual(datetime.date(2026, 4, 15), result)
def test_parses_by_iso_date(self):
result = _parse_due_date("please finish by 2026-04-15")
self.assertEqual(datetime.date(2026, 4, 15), result)
def test_returns_none_for_no_date(self):
self.assertIsNone(_parse_due_date("just a task description"))
def test_parses_due_today(self):
result = _parse_due_date("due today")
self.assertEqual(datetime.date.today(), result)
def test_parses_due_tomorrow(self):
result = _parse_due_date("by tomorrow")
self.assertEqual(datetime.date.today() + datetime.timedelta(days=1), result)
def test_parses_weekday_name(self):
result = _parse_due_date("by friday")
self.assertIsNotNone(result)
self.assertEqual(4, result.weekday()) # Friday = 4
def test_case_insensitive(self):
result = _parse_due_date("Due Today")
self.assertEqual(datetime.date.today(), result)
class AssigneeParsingTests(TestCase):
def test_parses_at_mention(self):
result = _parse_assignee("@alice please review this")
self.assertEqual("alice", result)
def test_parses_assign_to(self):
result = _parse_assignee("assign to bob")
self.assertEqual("bob", result)
def test_parses_for_person(self):
result = _parse_assignee("for charlie to fix by friday")
self.assertEqual("charlie", result)
def test_returns_empty_string_when_no_assignee(self):
result = _parse_assignee("no one mentioned")
self.assertEqual("", result)
def test_prefers_at_mention(self):
result = _parse_assignee("@dave assign to someone")
self.assertEqual("dave", result)
@override_settings(TASK_DERIVATION_USE_AI=False)
class TaskEnginePlan09Tests(TestCase):
def setUp(self):
self.user = User.objects.create_user("plan09-user", "plan09@example.com", "x")
self.person = Person.objects.create(user=self.user, name="Plan09 Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="signal",
identifier="+15559001234",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.project = TaskProject.objects.create(user=self.user, name="Plan09 Project")
ChatTaskSource.objects.create(
user=self.user,
service="signal",
channel_identifier="+15559001234",
project=self.project,
enabled=True,
)
def _msg(self, text: str, ts: int = 1000):
return Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text=text,
ts=ts,
source_service="signal",
source_chat_id="+15559001234",
)
def test_due_date_stored_in_model_field(self):
m = self._msg("task: update SSL cert by 2026-04-15")
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.get(origin_message=m)
self.assertEqual(datetime.date(2026, 4, 15), task.due_date)
def test_assignee_stored_in_model_field(self):
m = self._msg("task: review PR @alice")
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.get(origin_message=m)
self.assertEqual("alice", task.assignee_identifier)
def test_task_without_due_date_has_null_due_date(self):
m = self._msg("task: plain task no date")
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.get(origin_message=m)
self.assertIsNone(task.due_date)
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_dot_task_list_command(self, mocked_send):
seed = self._msg("task: fix the database issue", ts=1001)
async_to_sync(process_inbound_task_intelligence)(seed)
cmd = self._msg(".task list", ts=1002)
async_to_sync(process_inbound_task_intelligence)(cmd)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("open tasks" in row.lower() for row in payloads))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_dot_task_show_displays_task_detail(self, mocked_send):
seed = self._msg("task: deploy new version", ts=1003)
async_to_sync(process_inbound_task_intelligence)(seed)
task = DerivedTask.objects.get(origin_message=seed)
cmd = self._msg(f".task show #{task.reference_code}", ts=1004)
async_to_sync(process_inbound_task_intelligence)(cmd)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("deploy new version" in row.lower() for row in payloads))
self.assertTrue(any(str(task.reference_code) in row for row in payloads))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_dot_task_complete_marks_task_done(self, mocked_send):
seed = self._msg("task: restart services", ts=1005)
async_to_sync(process_inbound_task_intelligence)(seed)
task = DerivedTask.objects.get(origin_message=seed)
cmd = self._msg(f".task complete #{task.reference_code}", ts=1006)
async_to_sync(process_inbound_task_intelligence)(cmd)
task.refresh_from_db()
self.assertEqual("completed", task.status_snapshot)
self.assertTrue(
DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").exists()
)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("completed" in row.lower() for row in payloads))
def test_dot_task_complete_creates_audit_event(self):
seed = self._msg("task: patch kernel", ts=1007)
async_to_sync(process_inbound_task_intelligence)(seed)
task = DerivedTask.objects.get(origin_message=seed)
with patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock):
cmd = self._msg(f".task complete #{task.reference_code}", ts=1008)
async_to_sync(process_inbound_task_intelligence)(cmd)
event = DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").first()
self.assertIsNotNone(event)
self.assertIn("command", str(event.payload or {}).lower())
@override_settings(TASK_DERIVATION_USE_AI=False)
class TaskEngineMemoryContextTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("mem-ctx-user", "mem-ctx@example.com", "x")
self.person = Person.objects.create(user=self.user, name="Mem Ctx Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="whatsapp",
identifier="447700900001@s.whatsapp.net",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.project = TaskProject.objects.create(user=self.user, name="Mem Project")
ChatTaskSource.objects.create(
user=self.user,
service="whatsapp",
channel_identifier="447700900001@s.whatsapp.net",
project=self.project,
enabled=True,
)
def _msg(self, text: str, ts: int = 2000):
return Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text=text,
ts=ts,
source_service="whatsapp",
source_chat_id="447700900001@s.whatsapp.net",
)
def test_task_creation_invokes_memory_retrieval(self):
m = self._msg("task: deploy production release")
with patch(
"core.tasks.engine.retrieve_memories_for_prompt", return_value=[]
) as mock_retrieve:
async_to_sync(process_inbound_task_intelligence)(m)
self.assertTrue(mock_retrieve.called)
def test_memory_context_included_in_sync_event_payload(self):
from core.models import CodexRun
m = self._msg("task: fix authentication bug", ts=2001)
fake_memory = [{"id": "mem-1", "memory_kind": "fact", "content": {"text": "prefers short summaries"}}]
with patch("core.tasks.engine.retrieve_memories_for_prompt", return_value=fake_memory):
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.filter(origin_message=m).first()
self.assertIsNotNone(task)
run = CodexRun.objects.filter(task=task).order_by("-created_at").first()
self.assertIsNotNone(run, "Expected CodexRun created for task")
provider_payload = (run.request_payload or {}).get("provider_payload") or {}
memory_context = provider_payload.get("memory_context")
self.assertIsNotNone(memory_context, "Expected memory_context in CodexRun provider payload")
self.assertEqual(1, len(memory_context))