Reimplement compose and add tiling windows
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
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)
|
||||
@@ -1,285 +0,0 @@
|
||||
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)
|
||||
@@ -1,167 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from subprocess import CompletedProcess, TimeoutExpired
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.tasks.providers.codex_cli import CodexCLITaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProviderTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.provider = CodexCLITaskProvider()
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_healthcheck_success(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=["codex", "--version"],
|
||||
returncode=0,
|
||||
stdout="codex 1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.healthcheck({"command": "codex", "timeout_seconds": 5})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn("codex", str(result.payload.get("stdout") or ""))
|
||||
|
||||
@patch("core.tasks.providers.codex_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":"cx-123"}',
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.create_task(
|
||||
{
|
||||
"command": "codex",
|
||||
"workspace_root": "/tmp/work",
|
||||
"default_profile": "default",
|
||||
"timeout_seconds": 30,
|
||||
},
|
||||
{
|
||||
"task_id": "t1",
|
||||
"title": "hello",
|
||||
"reference_code": "42",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual("cx-123", result.external_key)
|
||||
args = run_mock.call_args.args[0]
|
||||
self.assertEqual(["codex", "task-sync", "--op", "create"], args[:4])
|
||||
self.assertIn("--workspace", args)
|
||||
self.assertIn("--payload-json", args)
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_timeout_maps_to_failed_result(self, run_mock):
|
||||
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
|
||||
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")
|
||||
)
|
||||
|
||||
@patch("core.tasks.providers.codex_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":"cx-42"}',
|
||||
stderr="",
|
||||
),
|
||||
]
|
||||
result = self.provider.create_task({"command": "codex"}, {"task_id": "t1"})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual("cx-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(["codex", "task-sync", "create"], second[:3])
|
||||
|
||||
@patch("core.tasks.providers.codex_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: codex [OPTIONS] [PROMPT]",
|
||||
),
|
||||
]
|
||||
result = self.provider.create_task(
|
||||
{"command": "codex"},
|
||||
{
|
||||
"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.codex_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: codex [OPTIONS] [PROMPT]",
|
||||
),
|
||||
]
|
||||
result = self.provider.append_update(
|
||||
{"command": "codex"},
|
||||
{
|
||||
"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 ""))
|
||||
@@ -1,289 +0,0 @@
|
||||
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,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
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("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()
|
||||
)
|
||||
|
||||
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):
|
||||
waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
provider="codex_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={},
|
||||
)
|
||||
req = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
external_sync_event=waiting_event,
|
||||
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()
|
||||
waiting_event.refresh_from_db()
|
||||
self.assertEqual("approved", req.status)
|
||||
self.assertEqual("approved_waiting_resume", run.status)
|
||||
self.assertEqual("ok", waiting_event.status)
|
||||
self.assertTrue(
|
||||
ExternalSyncEvent.objects.filter(
|
||||
idempotency_key="codex_approval:ak-123:approved", status="pending"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_approve_pre_submit_request_queues_original_action(self):
|
||||
waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
provider="codex_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="pre-ak-1",
|
||||
summary="pre submit",
|
||||
requested_permissions={"type": "pre_submit"},
|
||||
resume_payload={
|
||||
"gate_type": "pre_submit",
|
||||
"action": "append_update",
|
||||
"provider_payload": {"task_id": str(self.task.id), "mode": "default"},
|
||||
"idempotency_key": "codex_cmd:resume:1",
|
||||
},
|
||||
status="pending",
|
||||
)
|
||||
CommandChannelBinding.objects.get_or_create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="web",
|
||||
channel_identifier="approver-chan",
|
||||
defaults={"enabled": True},
|
||||
)
|
||||
trigger = self._msg(".codex approve pre-ak-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)
|
||||
resume = ExternalSyncEvent.objects.filter(
|
||||
idempotency_key="codex_cmd:resume:1"
|
||||
).first()
|
||||
self.assertIsNotNone(resume)
|
||||
self.assertEqual("pending", resume.status)
|
||||
self.assertEqual(
|
||||
"append_update", str((resume.payload or {}).get("action") or "")
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
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))
|
||||
|
||||
@patch("core.management.commands.codex_worker.get_provider")
|
||||
def test_approval_response_marks_original_waiting_event_ok(self, get_provider_mock):
|
||||
waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
status="waiting_approval",
|
||||
payload={
|
||||
"action": "append_update",
|
||||
"provider_payload": {"mode": "default"},
|
||||
},
|
||||
error="",
|
||||
)
|
||||
run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
status="approved_waiting_resume",
|
||||
request_payload={},
|
||||
result_payload={},
|
||||
)
|
||||
CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
external_sync_event=waiting_event,
|
||||
approval_key="ak-worker-ok",
|
||||
summary="needs permissions",
|
||||
requested_permissions={"items": ["write"]},
|
||||
resume_payload={"resume": True},
|
||||
status="approved",
|
||||
)
|
||||
resume_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
status="pending",
|
||||
payload={
|
||||
"action": "append_update",
|
||||
"provider_payload": {
|
||||
"mode": "approval_response",
|
||||
"approval_key": "ak-worker-ok",
|
||||
"codex_run_id": str(run.id),
|
||||
},
|
||||
},
|
||||
error="",
|
||||
)
|
||||
|
||||
class _Provider:
|
||||
run_in_worker = True
|
||||
|
||||
def append_update(self, config, payload):
|
||||
return ProviderResult(
|
||||
ok=True, payload={"status": "ok", "summary": "resumed"}
|
||||
)
|
||||
|
||||
create_task = mark_complete = link_task = append_update
|
||||
|
||||
get_provider_mock.return_value = _Provider()
|
||||
CodexWorkerCommand()._run_event(resume_event)
|
||||
|
||||
waiting_event.refresh_from_db()
|
||||
resume_event.refresh_from_db()
|
||||
self.assertEqual("ok", resume_event.status)
|
||||
self.assertEqual("ok", waiting_event.status)
|
||||
@@ -32,8 +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)")
|
||||
self.assertContains(response, "Claude (claude)")
|
||||
self.assertContains(response, "Business Plan (bp)")
|
||||
|
||||
def test_variant_policy_update_persists(self):
|
||||
response = self.client.post(
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import User
|
||||
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||
|
||||
|
||||
class ComposeSendCapabilityTests(TestCase):
|
||||
@@ -78,6 +78,9 @@ class ComposeSendCapabilityTests(TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode("utf-8")
|
||||
self.assertIn("compose-panel.css", content)
|
||||
self.assertIn("compose-panel-core.js", content)
|
||||
self.assertIn("compose-panel-thread.js", content)
|
||||
self.assertIn("compose-panel-send.js", content)
|
||||
self.assertIn("compose-panel.js", content)
|
||||
self.assertNotIn("const initialTyping = JSON.parse(", content)
|
||||
self.assertNotIn("data-drafts-url=", content)
|
||||
@@ -89,6 +92,33 @@ class ComposeSendCapabilityTests(TestCase):
|
||||
self.assertNotIn("compose-ticks", content)
|
||||
self.assertNotIn("compose-receipt-modal", content)
|
||||
|
||||
def test_compose_widget_declares_compose_assets_on_widget_shell(self):
|
||||
response = self.client.get(
|
||||
reverse("compose_widget"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode("utf-8")
|
||||
self.assertIn("data-gia-style-hrefs=", content)
|
||||
self.assertIn("/static/css/compose-panel.css", content)
|
||||
self.assertIn("data-gia-script-srcs=", content)
|
||||
self.assertIn("/static/js/compose-panel-core.js", content)
|
||||
self.assertIn("/static/js/compose-panel-thread.js", content)
|
||||
self.assertIn("/static/js/compose-panel-send.js", content)
|
||||
self.assertIn("/static/js/compose-panel.js", content)
|
||||
self.assertNotIn("<script defer src=\"/static/js/compose-panel.js\">", content)
|
||||
self.assertNotIn("<link rel=\"stylesheet\" href=\"/static/css/compose-panel.css\">", content)
|
||||
|
||||
def test_compose_contacts_dropdown_includes_workspace_link(self):
|
||||
response = self.client.get(reverse("compose_contacts_dropdown"))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, reverse("compose_workspace"))
|
||||
|
||||
@patch("core.views.compose._recent_manual_contacts")
|
||||
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
|
||||
mocked_recent_contacts.return_value = [
|
||||
@@ -129,6 +159,80 @@ class ComposeSendCapabilityTests(TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertIn("messages", payload)
|
||||
self.assertIn("messages_html", payload)
|
||||
self.assertIn("typing", payload)
|
||||
self.assertNotIn("availability_slices", payload)
|
||||
self.assertNotIn("availability_summary", payload)
|
||||
|
||||
def test_compose_thread_payload_includes_rendered_message_rows(self):
|
||||
person = Person.objects.create(user=self.user, name="Rendered Contact")
|
||||
identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service="signal",
|
||||
identifier="+15551230000",
|
||||
)
|
||||
session = ChatSession.objects.create(user=self.user, identifier=identifier)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
sender_uuid="contact",
|
||||
text="Rendered thread row",
|
||||
ts=1710000000000,
|
||||
custom_author="CONTACT",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("compose_thread"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertIsInstance(payload.get("messages_html"), str)
|
||||
self.assertIn("compose-row", str(payload.get("messages_html") or ""))
|
||||
self.assertIn("Rendered thread row", str(payload.get("messages_html") or ""))
|
||||
|
||||
def test_compose_thread_payload_renders_reply_link_text_server_side(self):
|
||||
person = Person.objects.create(user=self.user, name="Reply Contact")
|
||||
identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service="signal",
|
||||
identifier="+15551239999",
|
||||
)
|
||||
session = ChatSession.objects.create(user=self.user, identifier=identifier)
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
sender_uuid="contact",
|
||||
text="Anchor message for reply preview",
|
||||
ts=1710000000000,
|
||||
custom_author="CONTACT",
|
||||
)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
sender_uuid="self",
|
||||
text="Reply message",
|
||||
ts=1710000001000,
|
||||
custom_author="USER",
|
||||
reply_to=anchor,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("compose_thread"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551239999",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
html = str(payload.get("messages_html") or "")
|
||||
self.assertIn("Reply to: Anchor message for reply preview", html)
|
||||
self.assertNotIn("data-reply-preview=", html)
|
||||
|
||||
@@ -363,62 +363,8 @@ 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)
|
||||
self.assertTrue(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="signal",
|
||||
channel_identifier="+15550000002",
|
||||
enabled=True,
|
||||
).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(
|
||||
|
||||
@@ -3,15 +3,27 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import ChatSession, ConversationEvent, Person, PersonIdentifier, PlatformChatLink, User
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ConversationEvent,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
User,
|
||||
)
|
||||
from core.presence import latest_state_for_people
|
||||
from core.presence.inference import now_ms
|
||||
from core.views.compose import (
|
||||
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
|
||||
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE,
|
||||
_compose_availability_payload,
|
||||
_context_base,
|
||||
_filter_manual_contact_rows,
|
||||
_manual_contact_rows,
|
||||
_recent_manual_contacts,
|
||||
)
|
||||
@@ -78,6 +90,29 @@ class PresenceQueryAndComposeContextTests(TestCase):
|
||||
self.assertIsNotNone(base["person_identifier"])
|
||||
self.assertEqual(str(self.person.id), str(base["person"].id))
|
||||
|
||||
def test_filter_manual_contact_rows_matches_person_identifier_and_service(self):
|
||||
rows = [
|
||||
{
|
||||
"person_name": "Alice Example",
|
||||
"linked_person_name": "Alice Example",
|
||||
"detected_name": "",
|
||||
"service": "whatsapp",
|
||||
"identifier": "447700000001",
|
||||
},
|
||||
{
|
||||
"person_name": "Bob Example",
|
||||
"linked_person_name": "Bob Example",
|
||||
"detected_name": "",
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
},
|
||||
]
|
||||
|
||||
filtered = _filter_manual_contact_rows(rows, "alice whatsapp 000001")
|
||||
|
||||
self.assertEqual(1, len(filtered))
|
||||
self.assertEqual("Alice Example", filtered[0]["person_name"])
|
||||
|
||||
@patch("core.views.compose._manual_contact_rows")
|
||||
def test_recent_manual_contacts_keeps_current_person_name_for_signal_alias(
|
||||
self, mocked_manual_contact_rows
|
||||
@@ -433,3 +468,147 @@ class PresenceQueryAndComposeContextTests(TestCase):
|
||||
self.assertTrue(bool(rows[0].get("linked_person")))
|
||||
self.assertEqual("Mapped Contact", str(rows[0].get("linked_person_name") or ""))
|
||||
self.assertEqual("Detected Contact", str(rows[0].get("detected_name") or ""))
|
||||
|
||||
|
||||
class ComposeWorkspaceContactsWidgetTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"workspace-user",
|
||||
"workspace@example.com",
|
||||
"x",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
for index in range(65):
|
||||
person = Person.objects.create(
|
||||
user=self.user,
|
||||
name=f"Person {index:03d}",
|
||||
)
|
||||
PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service="whatsapp",
|
||||
identifier=f"4477000{index:03d}",
|
||||
)
|
||||
|
||||
def test_workspace_contact_widget_limits_initial_render(self):
|
||||
response = self.client.get(reverse("compose_workspace_contacts_widget"))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("Person 000", content)
|
||||
self.assertIn(
|
||||
f"Showing {COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE} contacts.",
|
||||
content,
|
||||
)
|
||||
self.assertNotIn("Window", content)
|
||||
self.assertNotIn("Match</span>", content)
|
||||
self.assertNotIn(f"Person {COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE:03d}", content)
|
||||
self.assertIn(
|
||||
f"Show {COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE} more",
|
||||
content,
|
||||
)
|
||||
|
||||
def test_workspace_contact_widget_results_fragment_filters_rows(self):
|
||||
response = self.client.get(
|
||||
reverse("compose_workspace_contacts_widget"),
|
||||
{"fragment": "results", "q": "Person 064"},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("Person 064", content)
|
||||
self.assertNotIn("Person 000", content)
|
||||
self.assertNotIn('id="widget-', content)
|
||||
|
||||
def test_compose_workspace_page_uses_shared_workspace_shell_asset(self):
|
||||
response = self.client.get(reverse("compose_workspace"))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("js/workspace-shell.js", content)
|
||||
self.assertNotIn("window.giaPrepareWidgetTarget = function", content)
|
||||
|
||||
|
||||
class ComposeWorkspaceHistoryWidgetTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"history-user",
|
||||
"history@example.com",
|
||||
"x",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
self.person_signal = Person.objects.create(user=self.user, name="Ada Signal")
|
||||
self.person_whatsapp = Person.objects.create(
|
||||
user=self.user,
|
||||
name="Willa WhatsApp",
|
||||
)
|
||||
self.signal_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person_signal,
|
||||
service="signal",
|
||||
identifier="15551230001",
|
||||
)
|
||||
self.whatsapp_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person_whatsapp,
|
||||
service="whatsapp",
|
||||
identifier="447700000001",
|
||||
)
|
||||
self.signal_session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.signal_identifier,
|
||||
)
|
||||
self.whatsapp_session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.whatsapp_identifier,
|
||||
)
|
||||
base_ts = now_ms()
|
||||
for index in range(32):
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.signal_session,
|
||||
ts=base_ts + index,
|
||||
text=f"Ada message {index:02d}",
|
||||
custom_author="USER" if index % 2 else "",
|
||||
)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.whatsapp_session,
|
||||
ts=base_ts - 1000,
|
||||
text="WhatsApp follow up",
|
||||
custom_author="",
|
||||
)
|
||||
|
||||
def test_history_widget_limits_initial_render(self):
|
||||
response = self.client.get(reverse("compose_workspace_history_widget"))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("Ada message 31", content)
|
||||
lower_bound = 32 - COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE
|
||||
self.assertIn(f"Ada message {lower_bound:02d}", content)
|
||||
self.assertNotIn(f"Ada message {lower_bound - 1:02d}", content)
|
||||
self.assertIn("Show more history", content)
|
||||
|
||||
def test_history_widget_filters_by_service_and_fragment_mode(self):
|
||||
response = self.client.get(
|
||||
reverse("compose_workspace_history_widget"),
|
||||
{"fragment": "results", "service": "whatsapp", "q": "follow up"},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("WhatsApp follow up", content)
|
||||
self.assertNotIn("Ada message 32", content)
|
||||
self.assertNotIn('id="widget-', content)
|
||||
|
||||
def test_history_widget_respects_person_scope(self):
|
||||
response = self.client.get(
|
||||
reverse("compose_workspace_history_widget"),
|
||||
{"person": str(self.person_signal.id)},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode()
|
||||
self.assertIn("Scoped to <strong>Ada Signal</strong>.", content)
|
||||
self.assertNotIn("WhatsApp follow up", content)
|
||||
|
||||
@@ -30,8 +30,8 @@ class SettingsIntegrityTests(TestCase):
|
||||
def test_capability_registry_excludes_removed_totp_scope(self):
|
||||
self.assertNotIn("gateway.totp", all_scope_keys())
|
||||
|
||||
def test_codex_settings_receives_modules_settings_nav(self):
|
||||
response = self.client.get(reverse("codex_settings"))
|
||||
def test_tasks_settings_receives_modules_settings_nav(self):
|
||||
response = self.client.get(reverse("tasks_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
settings_nav = response.context.get("settings_nav")
|
||||
self.assertIsNotNone(settings_nav)
|
||||
@@ -39,6 +39,9 @@ class SettingsIntegrityTests(TestCase):
|
||||
labels = [str(item["label"]) for item in settings_nav["tabs"]]
|
||||
self.assertIn("Commands", labels)
|
||||
self.assertIn("Task Automation", labels)
|
||||
self.assertNotIn("Codex", labels)
|
||||
group_labels = [str(item["label"]) for item in settings_nav["groups"]]
|
||||
self.assertEqual(["General", "Security", "AI", "Modules"], group_labels)
|
||||
|
||||
def test_business_plan_inbox_receives_modules_settings_nav(self):
|
||||
response = self.client.get(reverse("business_plan_inbox"))
|
||||
@@ -46,6 +49,8 @@ class SettingsIntegrityTests(TestCase):
|
||||
settings_nav = response.context.get("settings_nav")
|
||||
self.assertIsNotNone(settings_nav)
|
||||
self.assertEqual("Modules", settings_nav["title"])
|
||||
active_tabs = [str(item["label"]) for item in settings_nav["tabs"] if item["active"]]
|
||||
self.assertEqual(["Business Plans"], active_tabs)
|
||||
|
||||
def test_behavioral_settings_receives_modules_settings_nav(self):
|
||||
response = self.client.get(reverse("behavioral_signals_settings"))
|
||||
@@ -56,6 +61,17 @@ class SettingsIntegrityTests(TestCase):
|
||||
labels = [str(item["label"]) for item in settings_nav["tabs"]]
|
||||
self.assertIn("Behavioral Signals", labels)
|
||||
|
||||
def test_security_settings_receives_group_and_child_tabs(self):
|
||||
response = self.client.get(reverse("encryption_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
settings_nav = response.context.get("settings_nav")
|
||||
self.assertIsNotNone(settings_nav)
|
||||
self.assertEqual("Security", settings_nav["title"])
|
||||
active_groups = [str(item["label"]) for item in settings_nav["groups"] if item["active"]]
|
||||
self.assertEqual(["Security"], active_groups)
|
||||
active_tabs = [str(item["label"]) for item in settings_nav["tabs"] if item["active"]]
|
||||
self.assertEqual(["Encryption"], active_tabs)
|
||||
|
||||
def test_tasks_settings_cross_links_commands_and_permissions(self):
|
||||
TaskProject.objects.create(user=self.user, name="Integrity Project")
|
||||
response = self.client.get(reverse("tasks_settings"))
|
||||
@@ -79,12 +95,14 @@ class SettingsIntegrityTests(TestCase):
|
||||
self.assertContains(response, reverse("tasks_settings"))
|
||||
self.assertContains(response, reverse("permission_settings"))
|
||||
|
||||
def test_settings_nav_includes_codex_approval_route(self):
|
||||
request = self.factory.post(reverse("codex_approval"))
|
||||
def test_settings_nav_includes_tasks_route(self):
|
||||
request = self.factory.get(reverse("tasks_settings"))
|
||||
request.user = self.user
|
||||
request.resolver_match = resolve(reverse("codex_approval"))
|
||||
request.resolver_match = resolve(reverse("tasks_settings"))
|
||||
settings_nav = settings_hierarchy_nav(request)["settings_nav"]
|
||||
self.assertEqual("Modules", settings_nav["title"])
|
||||
active_tabs = [str(item["label"]) for item in settings_nav["tabs"] if item["active"]]
|
||||
self.assertEqual(["Task Automation"], active_tabs)
|
||||
|
||||
def test_settings_nav_includes_translation_preview_route(self):
|
||||
request = self.factory.post(reverse("translation_preview"))
|
||||
@@ -92,3 +110,18 @@ class SettingsIntegrityTests(TestCase):
|
||||
request.resolver_match = resolve(reverse("translation_preview"))
|
||||
settings_nav = settings_hierarchy_nav(request)["settings_nav"]
|
||||
self.assertEqual("Modules", settings_nav["title"])
|
||||
active_tabs = [str(item["label"]) for item in settings_nav["tabs"] if item["active"]]
|
||||
self.assertEqual(["Translation"], active_tabs)
|
||||
|
||||
def test_settings_pages_with_shared_shell_render_section_container(self):
|
||||
for route_name in (
|
||||
"notifications_settings",
|
||||
"security_2fa",
|
||||
"ai_models",
|
||||
"ai_execution_log",
|
||||
):
|
||||
with self.subTest(route_name=route_name):
|
||||
response = self.client.get(reverse(route_name))
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, '<section class="section">', html=False)
|
||||
self.assertContains(response, '<div class="container">', html=False)
|
||||
|
||||
@@ -11,6 +11,7 @@ from core.models import (
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
@@ -228,8 +229,6 @@ class TaskEngineMemoryContextTests(TestCase):
|
||||
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 = [
|
||||
{
|
||||
@@ -244,11 +243,13 @@ class TaskEngineMemoryContextTests(TestCase):
|
||||
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 {}
|
||||
event = ExternalSyncEvent.objects.filter(task=task).order_by("-created_at").first()
|
||||
self.assertIsNotNone(event, "Expected ExternalSyncEvent created for task")
|
||||
provider_payload = (
|
||||
((event.payload or {}).get("provider_payload") or {}) if event else {}
|
||||
)
|
||||
memory_context = provider_payload.get("memory_context")
|
||||
self.assertIsNotNone(
|
||||
memory_context, "Expected memory_context in CodexRun provider payload"
|
||||
memory_context, "Expected memory_context in sync event payload"
|
||||
)
|
||||
self.assertEqual(1, len(memory_context))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.management.commands.codex_worker import Command as LegacyWorkerCommand
|
||||
from core.management.commands.task_sync_worker import Command as TaskSyncWorkerCommand
|
||||
|
||||
|
||||
class TaskSyncWorkerCommandAliasTests(SimpleTestCase):
|
||||
def test_task_sync_worker_is_legacy_worker_alias(self):
|
||||
self.assertTrue(issubclass(TaskSyncWorkerCommand, LegacyWorkerCommand))
|
||||
@@ -9,11 +9,7 @@ from django.urls import reverse
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ChatTaskSource,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
ExternalChatLink,
|
||||
ExternalSyncEvent,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
@@ -134,7 +130,10 @@ class TaskAnnounceToggleTests(TestCase):
|
||||
self.assertIn("bp", names)
|
||||
self.assertIn("bp set", names)
|
||||
self.assertIn("bp set range", names)
|
||||
self.assertIn("codex", names)
|
||||
self.assertEqual(
|
||||
{"bp", "bp set", "bp set range"},
|
||||
{name for name in names if name.startswith("bp")},
|
||||
)
|
||||
|
||||
|
||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||
@@ -402,200 +401,24 @@ class TaskHubEmptyProjectVisibilityTests(TestCase):
|
||||
self.assertEqual(["Empty", "Used"], names)
|
||||
|
||||
|
||||
class TaskSettingsExternalChatLinkScopeTests(TestCase):
|
||||
class MockProviderSettingsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"task-link-user", "task-link@example.com", "x"
|
||||
"task-provider-user", "task-provider@example.com", "x"
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
self.group_person = Person.objects.create(user=self.user, name="Scoped Group")
|
||||
self.group_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.group_person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.group_identifier_bare = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.group_person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215",
|
||||
)
|
||||
self.other_person = Person.objects.create(user=self.user, name="Other Group")
|
||||
self.other_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.other_person,
|
||||
service="whatsapp",
|
||||
identifier="120399999999999999@g.us",
|
||||
)
|
||||
|
||||
def test_scoped_settings_limits_contact_identifier_options(self):
|
||||
response = self.client.get(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"service": "whatsapp",
|
||||
"identifier": "120363402761690215@g.us",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
options = list(response.context["external_link_person_identifiers"])
|
||||
self.assertTrue(any(row.id == self.group_identifier.id for row in options))
|
||||
self.assertTrue(any(row.id == self.group_identifier_bare.id for row in options))
|
||||
self.assertFalse(any(row.id == self.other_identifier.id for row in options))
|
||||
self.assertTrue(bool(response.context["external_link_scoped"]))
|
||||
|
||||
def test_scoped_upsert_rejects_out_of_scope_identifier(self):
|
||||
response = self.client.post(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"action": "external_chat_link_upsert",
|
||||
"provider": "codex_cli",
|
||||
"person_identifier_id": str(self.other_identifier.id),
|
||||
"external_chat_id": "codex-chat-abc",
|
||||
"enabled": "1",
|
||||
"prefill_service": "whatsapp",
|
||||
"prefill_identifier": "120363402761690215@g.us",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertFalse(
|
||||
ExternalChatLink.objects.filter(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
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):
|
||||
|
||||
def test_provider_update_persists_mock_enabled_state(self):
|
||||
response = self.client.post(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"action": "provider_update",
|
||||
"provider": "codex_cli",
|
||||
"provider": "mock",
|
||||
"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")
|
||||
cfg = TaskProviderConfig.objects.get(user=self.user, provider="mock")
|
||||
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)
|
||||
run = (
|
||||
CodexRun.objects.filter(user=self.user, task=self.task)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual("waiting_approval", str(getattr(run, "status", "")))
|
||||
event = (
|
||||
ExternalSyncEvent.objects.filter(
|
||||
user=self.user, task=self.task, provider="codex_cli"
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual("waiting_approval", str(getattr(event, "status", "")))
|
||||
self.assertTrue(
|
||||
CodexPermissionRequest.objects.filter(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
status="pending",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_codex_settings_page_and_approval_action(self):
|
||||
waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
provider="codex_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={},
|
||||
)
|
||||
req = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
external_sync_event=waiting_event,
|
||||
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()
|
||||
waiting_event.refresh_from_db()
|
||||
self.assertEqual("approved", req.status)
|
||||
self.assertEqual("approved_waiting_resume", run.status)
|
||||
self.assertEqual("ok", waiting_event.status)
|
||||
self.assertEqual({}, dict(cfg.settings or {}))
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase
|
||||
|
||||
from core.gateway.builtin import (
|
||||
gateway_help_lines,
|
||||
handle_approval_command,
|
||||
handle_tasks_command,
|
||||
)
|
||||
from core.models import (
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
ExternalSyncEvent,
|
||||
TaskProject,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class XMPPGatewayApprovalCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"xmpp-approval-user", "xmpp-approval@example.com", "x"
|
||||
)
|
||||
self.project = TaskProject.objects.create(
|
||||
user=self.user, name="Approval Project"
|
||||
)
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Approve me",
|
||||
source_service="xmpp",
|
||||
source_channel="component.example.test",
|
||||
reference_code="77",
|
||||
status_snapshot="open",
|
||||
)
|
||||
self.waiting_event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
provider="codex_cli",
|
||||
status="waiting_approval",
|
||||
payload={},
|
||||
error="",
|
||||
)
|
||||
self.run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
project=self.project,
|
||||
source_service="xmpp",
|
||||
source_channel="component.example.test",
|
||||
status="waiting_approval",
|
||||
request_payload={
|
||||
"action": "append_update",
|
||||
"provider_payload": {"task_id": str(self.task.id)},
|
||||
},
|
||||
result_payload={},
|
||||
)
|
||||
self.request = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=self.run,
|
||||
external_sync_event=self.waiting_event,
|
||||
approval_key="ak-xmpp-1",
|
||||
summary="Need auth approval",
|
||||
requested_permissions={"items": ["workspace_write"]},
|
||||
resume_payload={},
|
||||
status="pending",
|
||||
)
|
||||
self.probe = MagicMock()
|
||||
|
||||
def _run_command(self, text: str) -> list[str]:
|
||||
messages = []
|
||||
|
||||
def _sym(value):
|
||||
messages.append(str(value))
|
||||
|
||||
handled = async_to_sync(handle_approval_command)(
|
||||
self.user,
|
||||
text,
|
||||
_sym,
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(messages)
|
||||
return messages
|
||||
|
||||
def test_approval_approve_command_resolves_request_and_queues_resume(self):
|
||||
rows = self._run_command(".approval approve ak-xmpp-1")
|
||||
self.assertIn("approved", "\n".join(rows).lower())
|
||||
self.request.refresh_from_db()
|
||||
self.run.refresh_from_db()
|
||||
self.waiting_event.refresh_from_db()
|
||||
self.assertEqual("approved", self.request.status)
|
||||
self.assertEqual("approved_waiting_resume", self.run.status)
|
||||
self.assertEqual("ok", self.waiting_event.status)
|
||||
resume = ExternalSyncEvent.objects.filter(
|
||||
idempotency_key="codex_approval:ak-xmpp-1:approved"
|
||||
).first()
|
||||
self.assertIsNotNone(resume)
|
||||
self.assertEqual("pending", resume.status)
|
||||
|
||||
def test_approval_list_pending_and_status(self):
|
||||
rows = self._run_command(".approval list-pending all")
|
||||
text = "\n".join(rows)
|
||||
self.assertIn("pending=1", text)
|
||||
self.assertIn("ak-xmpp-1", text)
|
||||
status_rows = self._run_command(".approval status ak-xmpp-1")
|
||||
self.assertIn("status=pending", "\n".join(status_rows))
|
||||
|
||||
def test_provider_specific_command_rejects_mismatched_key(self):
|
||||
rows = self._run_command(".claude approve ak-xmpp-1")
|
||||
self.assertIn("approval_key_not_for_provider", "\n".join(rows))
|
||||
self.request.refresh_from_db()
|
||||
self.assertEqual("pending", self.request.status)
|
||||
|
||||
|
||||
class XMPPGatewayTasksCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"xmpp-task-user", "xmpp-task@example.com", "x"
|
||||
)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Task Project")
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Ship CLI",
|
||||
source_service="xmpp",
|
||||
source_channel="component.example.test",
|
||||
reference_code="12",
|
||||
status_snapshot="open",
|
||||
)
|
||||
self.probe = MagicMock()
|
||||
|
||||
def _run_tasks(self, text: str) -> list[str]:
|
||||
messages = []
|
||||
|
||||
def _sym(value):
|
||||
messages.append(str(value))
|
||||
|
||||
handled = async_to_sync(handle_tasks_command)(
|
||||
self.user,
|
||||
text,
|
||||
_sym,
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(messages)
|
||||
return messages
|
||||
|
||||
def test_help_contains_approval_and_tasks_sections(self):
|
||||
lines = gateway_help_lines()
|
||||
text = "\n".join(lines)
|
||||
self.assertIn(".approval list-pending", text)
|
||||
self.assertIn(".tasks list", text)
|
||||
self.assertIn(".tasks add", text)
|
||||
self.assertIn(".l", text)
|
||||
|
||||
def test_tasks_list_show_complete_and_undo(self):
|
||||
rows = self._run_tasks(".tasks list open 10")
|
||||
self.assertIn("#12", "\n".join(rows))
|
||||
rows = self._run_tasks(".l")
|
||||
self.assertIn("#12", "\n".join(rows))
|
||||
rows = self._run_tasks(".tasks show #12")
|
||||
self.assertIn("status: open", "\n".join(rows))
|
||||
rows = self._run_tasks(".tasks complete #12")
|
||||
self.assertIn("completed #12", "\n".join(rows))
|
||||
self.task.refresh_from_db()
|
||||
self.assertEqual("completed", self.task.status_snapshot)
|
||||
rows = self._run_tasks(".tasks undo #12")
|
||||
self.assertIn("removed #12", "\n".join(rows))
|
||||
self.assertFalse(DerivedTask.objects.filter(id=self.task.id).exists())
|
||||
|
||||
def test_tasks_add_creates_task_in_named_project(self):
|
||||
rows = []
|
||||
handled = async_to_sync(handle_tasks_command)(
|
||||
self.user,
|
||||
".tasks add Task Project :: Wire XMPP manual task create",
|
||||
lambda value: rows.append(str(value)),
|
||||
service="xmpp",
|
||||
channel_identifier="component.example.test",
|
||||
sender_identifier="operator@example.test",
|
||||
)
|
||||
self.assertTrue(handled)
|
||||
self.assertTrue(any("created #" in row.lower() for row in rows))
|
||||
created = DerivedTask.objects.filter(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
title="Wire XMPP manual task create",
|
||||
source_service="xmpp",
|
||||
source_channel="component.example.test",
|
||||
).first()
|
||||
self.assertIsNotNone(created)
|
||||
Reference in New Issue
Block a user