Implement plans

This commit is contained in:
2026-03-04 02:19:22 +00:00
parent 34ee49410d
commit 0718a06c19
31 changed files with 3987 additions and 181 deletions

View File

@@ -79,6 +79,13 @@ class BPSubcommandTests(TransactionTestCase):
parsed = parse_bp_subcommand("#bp set range# now")
self.assertEqual("set_range", parsed.command)
def test_parser_detects_dot_prefix_forms(self):
parsed = parse_bp_subcommand(".BP set addendum text")
self.assertEqual("set", parsed.command)
self.assertEqual("addendum text", parsed.remainder_text)
parsed_range = parse_bp_subcommand(".bp set range")
self.assertEqual("set_range", parsed_range.command)
def test_set_standalone_uses_remainder_only(self):
trigger = Message.objects.create(
user=self.user,

View File

@@ -58,3 +58,16 @@ class CodexCLITaskProviderTests(SimpleTestCase):
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
self.assertFalse(result.ok)
self.assertIn("timeout", result.error)
@patch("core.tasks.providers.codex_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": "codex"}, {"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"))

View File

@@ -0,0 +1,193 @@
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.codex import parse_codex_command
from core.models import (
ChatSession,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest,
CodexRun,
DerivedTask,
ExternalSyncEvent,
Message,
Person,
PersonIdentifier,
TaskProject,
TaskProviderConfig,
User,
)
class CodexCommandParserTests(TestCase):
def test_parse_variants(self):
self.assertEqual("default", parse_codex_command("#codex# run this").command)
self.assertEqual("plan", parse_codex_command("#codex plan# run this").command)
self.assertEqual("status", parse_codex_command("#codex status#").command)
parsed = parse_codex_command("#codex approve abc123#")
self.assertEqual("approve", parsed.command)
self.assertEqual("abc123", parsed.approval_key)
self.assertEqual("default", parse_codex_command(".codex run this").command)
self.assertEqual("plan", parse_codex_command(".CODEX plan run this").command)
self.assertEqual("status", parse_codex_command(".codex status").command)
parsed_dot = parse_codex_command(".codex approve abc123")
self.assertEqual("approve", parsed_dot.command)
self.assertEqual("abc123", parsed_dot.approval_key)
class CodexCommandExecutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-cmd-user", "codex-cmd@example.com", "x")
self.person = Person.objects.create(user=self.user, name="Codex 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="codex",
name="Codex",
enabled=True,
trigger_token="#codex#",
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="codex_cli",
enabled=True,
settings={
"command": "codex",
"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("#codex# 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("queued", run.status)
event = ExternalSyncEvent.objects.order_by("-created_at").first()
self.assertEqual("pending", event.status)
self.assertEqual("default", str((event.payload or {}).get("provider_payload", {}).get("mode") or ""))
def test_plan_requires_reply_anchor(self):
trigger = self._msg("#codex 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_codex_plan", results[0].error)
def test_approve_command_queues_resume_event(self):
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={},
)
req = CodexPermissionRequest.objects.create(
user=self.user,
codex_run=run,
approval_key="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("#codex approve 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)
req.refresh_from_db()
run.refresh_from_db()
self.assertEqual("approved", req.status)
self.assertEqual("approved_waiting_resume", run.status)
self.assertTrue(
ExternalSyncEvent.objects.filter(idempotency_key="codex_approval:ak-123:approved", status="pending").exists()
)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from unittest.mock import patch
from django.test import TestCase
from core.management.commands.codex_worker import Command as CodexWorkerCommand
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProject, TaskProviderConfig, User
from core.tasks.providers.base import ProviderResult
class CodexWorkerPhase1Tests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-worker-user", "codex-worker@example.com", "x")
self.project = TaskProject.objects.create(user=self.user, name="Worker Project")
self.cfg = TaskProviderConfig.objects.create(
user=self.user,
provider="codex_cli",
enabled=True,
settings={
"command": "codex",
"workspace_root": "",
"default_profile": "",
"timeout_seconds": 60,
"chat_link_mode": "task-sync",
"instance_label": "default",
"approver_mode": "channel",
"approver_service": "",
"approver_identifier": "",
},
)
@patch("core.management.commands.codex_worker.get_provider")
def test_pending_to_ok_updates_run(self, get_provider_mock):
run = CodexRun.objects.create(
user=self.user,
project=self.project,
source_service="web",
source_channel="web-chan-1",
status="queued",
request_payload={},
result_payload={},
)
event = ExternalSyncEvent.objects.create(
user=self.user,
provider="codex_cli",
status="pending",
payload={
"action": "append_update",
"provider_payload": {
"codex_run_id": str(run.id),
},
},
)
class _Provider:
run_in_worker = True
def append_update(self, config, payload):
return ProviderResult(ok=True, payload={"status": "ok", "summary": "done"})
create_task = mark_complete = link_task = append_update
get_provider_mock.return_value = _Provider()
CodexWorkerCommand()._run_event(event)
event.refresh_from_db()
run.refresh_from_db()
self.assertEqual("ok", event.status)
self.assertEqual("ok", run.status)
self.assertEqual("done", str(run.result_payload.get("summary") or ""))
@patch("core.management.commands.codex_worker.get_provider")
def test_requires_approval_moves_to_waiting_and_creates_permission_request(self, get_provider_mock):
run = CodexRun.objects.create(
user=self.user,
project=self.project,
source_service="web",
source_channel="web-chan-1",
status="queued",
request_payload={},
result_payload={},
)
event = ExternalSyncEvent.objects.create(
user=self.user,
provider="codex_cli",
status="pending",
payload={
"action": "append_update",
"provider_payload": {
"codex_run_id": str(run.id),
},
},
)
class _Provider:
run_in_worker = True
def append_update(self, config, payload):
return ProviderResult(
ok=True,
payload={
"status": "requires_approval",
"requires_approval": True,
"approval_key": "ak-worker-1",
"summary": "needs permissions",
"permission_request": {"requested_permissions": ["write"]},
"resume_payload": {"resume": True},
},
)
create_task = mark_complete = link_task = append_update
get_provider_mock.return_value = _Provider()
CodexWorkerCommand()._run_event(event)
event.refresh_from_db()
run.refresh_from_db()
self.assertEqual("waiting_approval", event.status)
self.assertEqual("waiting_approval", run.status)
request = CodexPermissionRequest.objects.get(approval_key="ak-worker-1")
self.assertEqual("pending", request.status)
self.assertEqual(str(run.id), str(request.codex_run_id))

View File

@@ -32,6 +32,7 @@ class CommandRoutingVariantUITests(TestCase):
self.assertContains(response, "Variant Policies")
self.assertContains(response, "bp set range")
self.assertContains(response, "Send status to egress")
self.assertContains(response, "Codex (codex)")
def test_variant_policy_update_persists(self):
response = self.client.post(

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
from django.test import TestCase
from django.urls import reverse
from core.models import ChatSession, Message, Person, PersonIdentifier, User
class ComposeReactTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("compose-react", "react@example.com", "pw")
self.client.force_login(self.user)
def _build_message(self, *, service: str, identifier: str, source_message_id: str = ""):
person = Person.objects.create(user=self.user, name=f"{service} person")
person_identifier = PersonIdentifier.objects.create(
user=self.user,
person=person,
service=service,
identifier=identifier,
)
session = ChatSession.objects.create(
user=self.user,
identifier=person_identifier,
)
message = Message.objects.create(
user=self.user,
session=session,
ts=1770000000123,
sender_uuid="sender-1",
text="hello",
source_service=service,
source_message_id=source_message_id or "",
source_chat_id=identifier,
receipt_payload={},
)
return person, person_identifier, message
@patch("core.views.compose.history.apply_reaction", new_callable=AsyncMock)
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
def test_signal_react_uses_numeric_source_message_id_timestamp(
self, mocked_send_reaction, mocked_apply_reaction
):
person, _, message = self._build_message(
service="signal",
identifier="+15551230000",
source_message_id="1771234567000",
)
mocked_send_reaction.return_value = True
mocked_apply_reaction.return_value = message
response = self.client.post(
reverse("compose_react"),
{
"service": "signal",
"identifier": "+15551230000",
"person": str(person.id),
"message_id": str(message.id),
"emoji": "👍",
},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["ok"])
self.assertEqual("👍", payload["emoji"])
self.assertFalse(payload["remove"])
mocked_send_reaction.assert_awaited_once()
_, kwargs = mocked_send_reaction.await_args
self.assertEqual("signal", mocked_send_reaction.await_args.args[0])
self.assertEqual("+15551230000", mocked_send_reaction.await_args.args[1])
self.assertEqual(1771234567000, kwargs["target_timestamp"])
self.assertEqual("sender-1", kwargs["target_author"])
self.assertEqual("", kwargs["target_message_id"])
@patch("core.views.compose.history.apply_reaction", new_callable=AsyncMock)
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
def test_whatsapp_react_uses_upstream_message_id(
self, mocked_send_reaction, mocked_apply_reaction
):
person, _, message = self._build_message(
service="whatsapp",
identifier="12345@s.whatsapp.net",
source_message_id="wamid.ABC123",
)
mocked_send_reaction.return_value = True
mocked_apply_reaction.return_value = message
response = self.client.post(
reverse("compose_react"),
{
"service": "whatsapp",
"identifier": "12345@s.whatsapp.net",
"person": str(person.id),
"message_id": str(message.id),
"emoji": "❤️",
},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["ok"])
self.assertEqual("wamid.ABC123", payload["target_upstream_id"])
mocked_send_reaction.assert_awaited_once()
_, kwargs = mocked_send_reaction.await_args
self.assertEqual("wamid.ABC123", kwargs["target_message_id"])
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
def test_toggle_removes_existing_actor_reaction(self, mocked_send_reaction):
person, _, message = self._build_message(
service="signal",
identifier="+15551230000",
source_message_id="1771234567000",
)
message.receipt_payload = {
"reactions": [
{
"emoji": "👍",
"actor": f"web:{self.user.id}:signal",
"source_service": "signal",
"removed": False,
}
]
}
message.save(update_fields=["receipt_payload"])
mocked_send_reaction.return_value = True
with patch(
"core.views.compose.history.apply_reaction",
new=AsyncMock(return_value=message),
):
response = self.client.post(
reverse("compose_react"),
{
"service": "signal",
"identifier": "+15551230000",
"person": str(person.id),
"message_id": str(message.id),
"emoji": "👍",
},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["ok"])
self.assertTrue(payload["remove"])
_, kwargs = mocked_send_reaction.await_args
self.assertTrue(kwargs["remove"])
def test_unsupported_service_returns_error(self):
response = self.client.post(
reverse("compose_react"),
{
"service": "xmpp",
"identifier": "someone@example.com",
"message_id": "does-not-matter",
"emoji": "👍",
},
)
self.assertEqual(200, response.status_code)
self.assertEqual(
{"ok": False, "error": "service_not_supported"},
response.json(),
)
def test_missing_whatsapp_target_returns_error(self):
person, _, message = self._build_message(
service="whatsapp",
identifier="12345@s.whatsapp.net",
source_message_id="",
)
response = self.client.post(
reverse("compose_react"),
{
"service": "whatsapp",
"identifier": "12345@s.whatsapp.net",
"person": str(person.id),
"message_id": str(message.id),
"emoji": "😂",
},
)
self.assertEqual(200, response.status_code)
self.assertEqual(
{"ok": False, "error": "whatsapp_target_unresolvable"},
response.json(),
)
def test_compose_page_renders_reaction_actions_for_signal(self):
person, _, _ = self._build_message(
service="signal",
identifier="+15551230000",
source_message_id="1771234567000",
)
response = self.client.get(
reverse("compose_page"),
{
"service": "signal",
"identifier": "+15551230000",
"person": str(person.id),
},
)
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("data-react-url=", content)
self.assertIn("compose-reaction-actions", content)
def test_compose_page_hides_reaction_actions_for_unsupported_service(self):
person, _, _ = self._build_message(
service="xmpp",
identifier="person@example.com",
source_message_id="msg-1",
)
response = self.client.get(
reverse("compose_page"),
{
"service": "xmpp",
"identifier": "person@example.com",
"person": str(person.id),
},
)
self.assertEqual(200, response.status_code)
self.assertNotIn(
'class="compose-reaction-actions"',
response.content.decode("utf-8"),
)

View File

@@ -9,8 +9,10 @@ from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
from core.views.compose import _command_options_for_channel
from core.models import (
ChatSession,
CommandAction,
CommandChannelBinding,
CommandProfile,
CommandVariantPolicy,
Message,
Person,
PersonIdentifier,
@@ -313,4 +315,124 @@ class Phase1CommandEngineTests(TestCase):
self.assertIn("bp", names)
self.assertIn("bp set", names)
self.assertIn("bp set range", names)
self.assertIn("codex", names)
self.assertNotIn("announce task ids", names)
def test_first_user_codex_command_auto_enables_defaults_for_channel(self):
CommandProfile.objects.filter(user=self.user, slug="codex").delete()
msg = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="",
custom_author="USER",
text="#codex status#",
ts=6000,
source_service="web",
source_chat_id="web-chan-2",
message_meta={},
)
results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier="web-chan-2",
message_id=str(msg.id),
user_id=self.user.id,
message_text="#codex status#",
payload={},
)
)
self.assertEqual(1, len(results))
self.assertTrue(results[0].ok)
profile = CommandProfile.objects.filter(user=self.user, slug="codex").first()
self.assertIsNotNone(profile)
self.assertTrue(bool(profile.enabled if profile else False))
ingress_exists = CommandChannelBinding.objects.filter(
profile=profile,
direction="ingress",
enabled=True,
service="signal",
channel_identifier="+15550000002",
).exists()
egress_exists = CommandChannelBinding.objects.filter(
profile=profile,
direction="egress",
enabled=True,
service="signal",
channel_identifier="+15550000002",
).exists()
self.assertTrue(ingress_exists)
self.assertTrue(egress_exists)
def test_first_user_bp_command_auto_setup_is_idempotent(self):
CommandProfile.objects.filter(user=self.user, slug="bp").delete()
msg1 = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="",
custom_author="USER",
text="#bp#",
ts=7000,
source_service="web",
source_chat_id="web-chan-3",
message_meta={},
)
first_results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier="web-chan-3",
message_id=str(msg1.id),
user_id=self.user.id,
message_text="#bp#",
payload={},
)
)
self.assertEqual(1, len(first_results))
self.assertEqual("reply_required", first_results[0].error)
profile = CommandProfile.objects.filter(user=self.user, slug="bp").first()
self.assertIsNotNone(profile)
if profile is None:
return
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
self.assertEqual(
2,
CommandChannelBinding.objects.filter(
profile=profile,
service="signal",
channel_identifier="+15550000002",
).count(),
)
msg2 = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="",
custom_author="USER",
text="#bp#",
ts=8000,
source_service="web",
source_chat_id="web-chan-3",
message_meta={},
)
second_results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier="web-chan-3",
message_id=str(msg2.id),
user_id=self.user.id,
message_text="#bp#",
payload={},
)
)
self.assertEqual(1, len(second_results))
self.assertEqual("reply_required", second_results[0].error)
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
self.assertEqual(
2,
CommandChannelBinding.objects.filter(
profile=profile,
service="signal",
channel_identifier="+15550000002",
).count(),
)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
from asgiref.sync import async_to_sync
from django.test import TestCase, override_settings
@@ -13,6 +15,7 @@ from core.models import (
Person,
PersonIdentifier,
TaskCompletionPattern,
TaskEpic,
TaskProject,
User,
Message,
@@ -197,3 +200,208 @@ class TaskEngineTests(TestCase):
DerivedTask.objects.filter(origin_message=m).exists(),
"Expected Signal UUID source chat to match source mapping by companion number.",
)
def test_lenient_prefix_parsing_allows_hash_todo_in_strict_mode(self):
source = ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
).first()
source.settings = {
"derive_enabled": True,
"match_mode": "strict",
"require_prefix": True,
"allowed_prefixes": ["task:", "todo:"],
"ai_title_enabled": False,
}
source.save(update_fields=["settings", "updated_at"])
m = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="#todo: rotate SSL certs",
ts=1400,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.filter(origin_message=m).first()
self.assertIsNotNone(task)
self.assertEqual("rotate SSL certs", str(task.title or ""))
def test_lenient_prefix_parsing_allows_task_dash_form(self):
source = ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
).first()
source.settings = {
"derive_enabled": True,
"match_mode": "strict",
"require_prefix": True,
"allowed_prefixes": ["task:", "todo:"],
"ai_title_enabled": False,
}
source.save(update_fields=["settings", "updated_at"])
m = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="task - setup password sharing",
ts=1500,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.filter(origin_message=m).first()
self.assertIsNotNone(task)
self.assertEqual("setup password sharing", str(task.title or ""))
def test_lenient_prefix_parsing_allows_double_dot_task_form(self):
source = ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
).first()
source.settings = {
"derive_enabled": True,
"match_mode": "strict",
"require_prefix": True,
"allowed_prefixes": ["task:", "todo:"],
"ai_title_enabled": False,
}
source.save(update_fields=["settings", "updated_at"])
m = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="..task setup password sharing",
ts=1600,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.filter(origin_message=m).first()
self.assertIsNotNone(task)
self.assertEqual("setup password sharing", str(task.title or ""))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_dot_l_lists_tasks_in_scope(self, mocked_send):
seed = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="task: rotate keys",
ts=1700,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(seed)
cmd = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text=".l",
ts=1701,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(cmd)
self.assertTrue(mocked_send.await_count >= 1)
list_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 list_payloads))
self.assertTrue(any("#1" in row for row in list_payloads))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_dot_undo_uncreates_latest_task(self, mocked_send):
m1 = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="task: one",
ts=1800,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
m2 = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="task: two",
ts=1801,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(m1)
async_to_sync(process_inbound_task_intelligence)(m2)
self.assertEqual(2, DerivedTask.objects.filter(user=self.user, project=self.project).count())
cmd = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text=".undo",
ts=1802,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(cmd)
remaining = list(
DerivedTask.objects.filter(user=self.user, project=self.project)
.order_by("created_at")
.values_list("title", flat=True)
)
self.assertEqual(1, len(remaining))
self.assertEqual("one", remaining[0])
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("removed #2" in row.lower() for row in payloads))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_every_tenth_task_sends_l_and_undo_reminder(self, mocked_send):
for idx in range(1, 11):
m = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text=f"task: item {idx}",
ts=1900 + idx,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(m)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(
any(".l list tasks" in row.lower() and ".undo" in row.lower() for row in payloads),
"Expected periodic reminder to mention both .l and .undo.",
)
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
def test_epic_create_command_from_chat(self, mocked_send):
msg = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="epic: Security",
ts=2001,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(msg)
self.assertTrue(TaskEpic.objects.filter(project=self.project, name="Security").exists())
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("whatsapp usage" in row.lower() for row in payloads))
def test_task_with_epic_token_assigns_epic(self):
msg = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="task: setup ssl cert rotation [epic:Security]",
ts=2002,
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
)
async_to_sync(process_inbound_task_intelligence)(msg)
task = DerivedTask.objects.filter(origin_message=msg).first()
self.assertIsNotNone(task)
self.assertIsNotNone(task.epic)
self.assertEqual("Security", str(task.epic.name or ""))

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
from django.test import TestCase
from django.urls import reverse
@@ -73,6 +75,16 @@ class TasksPagesManagementTests(TestCase):
).exists()
)
def test_tasks_hub_settings_link_preserves_scope_context(self):
response = self.client.get(
f"{reverse('tasks_hub')}?person={self.person.id}&service=signal&identifier=147e75bd-91b7-4014-b9e5-12a44b978f7b"
)
self.assertEqual(200, response.status_code)
self.assertContains(
response,
f"{reverse('tasks_settings')}?person={self.person.id}&service=signal&identifier=147e75bd-91b7-4014-b9e5-12a44b978f7b",
)
def test_project_page_can_create_and_delete_epic(self):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
create_response = self.client.post(
@@ -98,6 +110,81 @@ class TasksPagesManagementTests(TestCase):
self.assertEqual(200, delete_response.status_code)
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
def test_project_page_can_assign_and_clear_task_epic(self):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
epic = TaskEpic.objects.create(project=project, name="Sprint A")
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
origin = Message.objects.create(
user=self.user,
session=session,
ts=1_700_000_000_100,
text="task: assign epic",
sender_uuid="+15551230000",
custom_author="OTHER",
source_service="signal",
source_chat_id="+15551230000",
)
task = DerivedTask.objects.create(
user=self.user,
project=project,
title="Assign me",
source_service="signal",
source_channel="+15551230000",
origin_message=origin,
reference_code="9",
status_snapshot="open",
)
assign_response = self.client.post(
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
{
"action": "task_set_epic",
"task_id": str(task.id),
"epic_id": str(epic.id),
},
follow=True,
)
self.assertEqual(200, assign_response.status_code)
task.refresh_from_db()
self.assertEqual(epic.id, task.epic_id)
clear_response = self.client.post(
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
{
"action": "task_set_epic",
"task_id": str(task.id),
"epic_id": "",
},
follow=True,
)
self.assertEqual(200, clear_response.status_code)
task.refresh_from_db()
self.assertIsNone(task.epic_id)
@patch("core.views.tasks.send_message_raw", new_callable=AsyncMock)
def test_project_epic_create_announces_to_project_chats(self, mocked_send):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
ChatTaskSource.objects.create(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
project=project,
enabled=True,
)
response = self.client.post(
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
{
"action": "epic_create",
"name": "Phase 2",
},
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertTrue(TaskEpic.objects.filter(project=project, name="Phase 2").exists())
self.assertTrue(mocked_send.await_count >= 1)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
self.assertTrue(any("whatsapp usage" in row.lower() for row in payloads))
self.assertTrue(any("add task to epic" in row.lower() for row in payloads))
def test_group_page_create_and_map_project(self):
response = self.client.post(
reverse(
@@ -156,6 +243,36 @@ class TasksPagesManagementTests(TestCase):
self.assertEqual(200, response.status_code)
self.assertContains(response, "Scope Person")
def test_project_page_creator_column_links_to_compose(self):
project = TaskProject.objects.create(user=self.user, name="Creator Link Test")
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
origin = Message.objects.create(
user=self.user,
session=session,
ts=1_700_000_000_111,
text="task: creator link",
sender_uuid="+15551230000",
custom_author="OTHER",
source_service="signal",
source_chat_id="+15551230000",
)
DerivedTask.objects.create(
user=self.user,
project=project,
title="Creator link task",
source_service="signal",
source_channel="+15551230000",
origin_message=origin,
reference_code="2",
status_snapshot="open",
)
response = self.client.get(reverse("tasks_project", kwargs={"project_id": str(project.id)}))
self.assertEqual(200, response.status_code)
self.assertContains(
response,
f'{reverse("compose_page")}?service=signal&identifier=%2B15551230000&person={self.person.id}',
)
def test_task_detail_renders_payload_summary_and_json(self):
project = TaskProject.objects.create(user=self.user, name="Payload Test")
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)

View File

@@ -9,12 +9,16 @@ from django.test import TestCase, override_settings
from core.models import (
ChatSession,
ChatTaskSource,
CodexPermissionRequest,
CodexRun,
DerivedTask,
ExternalSyncEvent,
ExternalChatLink,
Message,
Person,
PersonIdentifier,
TaskCompletionPattern,
TaskProviderConfig,
TaskProject,
User,
)
@@ -118,6 +122,7 @@ class TaskAnnounceToggleTests(TestCase):
self.assertIn("bp", names)
self.assertIn("bp set", names)
self.assertIn("bp set range", names)
self.assertIn("codex", names)
@override_settings(TASK_DERIVATION_USE_AI=False)
@@ -268,3 +273,97 @@ class TaskSettingsExternalChatLinkScopeTests(TestCase):
external_chat_id="codex-chat-abc",
).exists()
)
class CodexSettingsAndSubmitTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-settings-user", "codex-settings@example.com", "x")
self.client.force_login(self.user)
self.project = TaskProject.objects.create(user=self.user, name="Codex Project")
self.task = DerivedTask.objects.create(
user=self.user,
project=self.project,
epic=None,
title="Task X",
source_service="web",
source_channel="web-chan-1",
reference_code="11",
status_snapshot="open",
)
def test_provider_update_persists_phase1_codex_settings(self):
response = self.client.post(
reverse("tasks_settings"),
{
"action": "provider_update",
"provider": "codex_cli",
"enabled": "1",
"command": "codex",
"workspace_root": "/code/xf",
"default_profile": "default",
"timeout_seconds": "120",
"instance_label": "team-a",
"approver_service": "web",
"approver_identifier": "approver-chan",
},
follow=True,
)
self.assertEqual(200, response.status_code)
cfg = TaskProviderConfig.objects.get(user=self.user, provider="codex_cli")
self.assertTrue(cfg.enabled)
self.assertEqual("team-a", str(cfg.settings.get("instance_label") or ""))
self.assertEqual("web", str(cfg.settings.get("approver_service") or ""))
self.assertEqual("approver-chan", str(cfg.settings.get("approver_identifier") or ""))
def test_task_submit_endpoint_creates_codex_run_and_event(self):
TaskProviderConfig.objects.create(
user=self.user,
provider="codex_cli",
enabled=True,
settings={"command": "codex", "timeout_seconds": 60},
)
response = self.client.post(
reverse("tasks_codex_submit"),
{
"task_id": str(self.task.id),
"next": reverse("tasks_hub"),
},
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertTrue(CodexRun.objects.filter(user=self.user, task=self.task).exists())
self.assertTrue(ExternalSyncEvent.objects.filter(user=self.user, task=self.task, provider="codex_cli").exists())
def test_codex_settings_page_and_approval_action(self):
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={},
)
req = CodexPermissionRequest.objects.create(
user=self.user,
codex_run=run,
approval_key="approve-me",
summary="need approval",
requested_permissions={"items": ["write"]},
resume_payload={"resume": True},
status="pending",
)
response = self.client.get(reverse("codex_settings"))
self.assertEqual(200, response.status_code)
self.assertContains(response, "Codex Status")
response = self.client.post(
reverse("codex_approval"),
{"request_id": str(req.id), "decision": "approve"},
follow=True,
)
self.assertEqual(200, response.status_code)
req.refresh_from_db()
run.refresh_from_db()
self.assertEqual("approved", req.status)
self.assertEqual("approved_waiting_resume", run.status)