from __future__ import annotations import unittest from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import patch from uuid import uuid4 from web.software_followups import claim_followup class _Result: def __init__(self, value=None): self.value = value def scalar_one_or_none(self): return self.value class _Session: def __init__(self, results): self.results = list(results) self.added = [] self.statements = [] def execute(self, statement): self.statements.append(statement) return _Result(self.results.pop(0) if self.results else None) def add(self, row): self.added.append(row) def _job(action: str): job_id = uuid4() task_id = uuid4() return SimpleNamespace( job_id=job_id, task_id=task_id, user_id=uuid4(), capability="origin.plot@v2", status="succeeded", followup_status="pending", completion_action=action, artifact_manifest=[ { "artifact_id": str(uuid4()), "filename": "figure.png", "path": f"origin/{job_id}/figure.png", }, { "artifact_id": None, "filename": "plot-spec.json", "path": f"origin/{job_id}/.meta/plot-spec.json", }, ], ) class SoftwareFollowupTests(unittest.TestCase): def test_report_claim_persists_fixed_assistant_message_with_artifacts(self): job = _job("report") task = SimpleNamespace(task_id=job.task_id, run_status="idle") session = _Session([job.task_id, task, job]) @contextmanager def scope(): yield session with ( patch("web.software_followups.session_scope", scope), patch("web.software_followups.allocate_message_idx", return_value=7), ): claim = claim_followup(job.job_id) self.assertEqual(claim.action, "report") self.assertEqual(job.followup_status, "completed") self.assertEqual(len(session.added), 1) message = session.added[0] self.assertEqual(message.payload["role"], "assistant") self.assertIn(str(job.job_id), message.payload["content"]) self.assertEqual(len(message.artifact_refs), 1) self.assertEqual(message.artifact_refs[0]["label"], "figure.png") def test_analyze_claim_persists_internal_turn_and_locks_task(self): job = _job("analyze") task = SimpleNamespace(task_id=job.task_id, run_status="idle") session = _Session([job.task_id, task, job, None]) @contextmanager def scope(): yield session with ( patch("web.software_followups.session_scope", scope), patch("web.software_followups.allocate_message_idx", return_value=8), ): claim = claim_followup(job.job_id) self.assertEqual(claim.action, "analyze") self.assertEqual(job.followup_status, "running") self.assertEqual(session.added[0].payload["role"], "user") self.assertIn("software_job_status", session.added[0].payload["content"]) self.assertGreaterEqual(len(session.statements), 4) def test_busy_task_leaves_followup_pending(self): job = _job("analyze") task = SimpleNamespace(task_id=job.task_id, run_status="running") session = _Session([job.task_id, task]) @contextmanager def scope(): yield session with patch("web.software_followups.session_scope", scope): claim = claim_followup(job.job_id) self.assertIsNone(claim) self.assertEqual(job.followup_status, "pending") self.assertFalse(session.added) if __name__ == "__main__": unittest.main()