diff --git a/cheddahbot/agent.py b/cheddahbot/agent.py index 58b1217..421853a 100644 --- a/cheddahbot/agent.py +++ b/cheddahbot/agent.py @@ -45,10 +45,12 @@ def _build_file_content_parts(files: list[str]) -> list[dict]: try: data = base64.b64encode(p.read_bytes()).decode("utf-8") mime = _IMAGE_MIME[suffix] - parts.append({ - "type": "image_url", - "image_url": {"url": f"data:{mime};base64,{data}"}, - }) + parts.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{data}"}, + } + ) except Exception as e: parts.append({"type": "text", "text": f"[Error reading image {p.name}: {e}]"}) else: @@ -233,11 +235,13 @@ class Agent: } for i, tc in enumerate(unique_tool_calls) ] - messages.append({ - "role": "assistant", - "content": full_response or None, - "tool_calls": openai_tool_calls, - }) + messages.append( + { + "role": "assistant", + "content": full_response or None, + "tool_calls": openai_tool_calls, + } + ) for tc in unique_tool_calls: yield f"\n\n**Using tool: {tc['name']}**\n" @@ -248,11 +252,13 @@ class Agent: yield f"```\n{result[:2000]}\n```\n\n" self.db.add_message(conv_id, "tool", result, tool_result=tc["name"]) - messages.append({ - "role": "tool", - "tool_call_id": tc.get("id", f"call_{tc['name']}"), - "content": result, - }) + messages.append( + { + "role": "tool", + "tool_call_id": tc.get("id", f"call_{tc['name']}"), + "content": result, + } + ) else: # No tool system configured - just mention tool was requested if full_response: diff --git a/cheddahbot/db.py b/cheddahbot/db.py index 8d63eca..141e7fd 100644 --- a/cheddahbot/db.py +++ b/cheddahbot/db.py @@ -94,9 +94,7 @@ class Database: self._conn.commit() return conv_id - def list_conversations( - self, limit: int = 50, agent_name: str | None = None - ) -> list[dict]: + def list_conversations(self, limit: int = 50, agent_name: str | None = None) -> list[dict]: if agent_name: rows = self._conn.execute( "SELECT id, title, updated_at, agent_name FROM conversations" @@ -229,6 +227,11 @@ class Database: ).fetchall() return [(r["key"], r["value"]) for r in rows] + def kv_delete(self, key: str): + """Delete a key from the kv_store.""" + self._conn.execute("DELETE FROM kv_store WHERE key = ?", (key,)) + self._conn.commit() + # -- Notifications -- def add_notification(self, message: str, category: str = "clickup") -> int: diff --git a/cheddahbot/skills.py b/cheddahbot/skills.py index 97538b4..7856e25 100644 --- a/cheddahbot/skills.py +++ b/cheddahbot/skills.py @@ -116,10 +116,7 @@ class SkillRegistry: for skill in self._skills.values(): if skill.agents and agent_name not in skill.agents: continue - parts.append( - f"### Skill: {skill.name}\n" - f"{skill.description}\n" - ) + parts.append(f"### Skill: {skill.name}\n{skill.description}\n") if not parts: return "" diff --git a/cheddahbot/tools/clickup_tool.py b/cheddahbot/tools/clickup_tool.py index e34451f..efff258 100644 --- a/cheddahbot/tools/clickup_tool.py +++ b/cheddahbot/tools/clickup_tool.py @@ -95,7 +95,7 @@ def clickup_query_tasks(status: str = "", task_type: str = "", ctx: dict | None @tool( "clickup_list_tasks", "List ClickUp tasks that Cheddah is tracking. Optionally filter by internal state " - "(discovered, awaiting_approval, executing, completed, failed, declined, unmapped).", + "(executing, completed, failed).", category="clickup", ) def clickup_list_tasks(status: str = "", ctx: dict | None = None) -> str: @@ -164,55 +164,42 @@ def clickup_task_status(task_id: str, ctx: dict | None = None) -> str: @tool( - "clickup_approve_task", - "Approve a ClickUp task that is waiting for permission to execute.", + "clickup_reset_task", + "Reset a ClickUp task's internal tracking state so it can be retried on the next poll. " + "Use this when a task has failed or completed and you want to re-run it.", category="clickup", ) -def clickup_approve_task(task_id: str, ctx: dict | None = None) -> str: - """Approve a task in awaiting_approval state.""" +def clickup_reset_task(task_id: str, ctx: dict | None = None) -> str: + """Delete the kv_store state for a single task so it can be retried.""" db = ctx["db"] key = f"clickup:task:{task_id}:state" raw = db.kv_get(key) if not raw: - return f"No tracked state found for task ID '{task_id}'." + return f"No tracked state found for task ID '{task_id}'. Nothing to reset." - try: - state = json.loads(raw) - except json.JSONDecodeError: - return f"Corrupted state data for task '{task_id}'." - - if state.get("state") != "awaiting_approval": - current = state.get("state") - return f"Task '{task_id}' is in state '{current}', not 'awaiting_approval'. Cannot approve." - - state["state"] = "approved" - db.kv_set(key, json.dumps(state)) - name = state.get("clickup_task_name", task_id) - return f"Task '{name}' approved for execution. It will run on the next scheduler cycle." + db.kv_delete(key) + return f"Task '{task_id}' state cleared. It will be picked up on the next scheduler poll." @tool( - "clickup_decline_task", - "Decline a ClickUp task that is waiting for permission to execute.", + "clickup_reset_all", + "Clear ALL internal ClickUp task tracking state. Use this to wipe the slate clean " + "so all eligible tasks can be retried on the next poll cycle.", category="clickup", ) -def clickup_decline_task(task_id: str, ctx: dict | None = None) -> str: - """Decline a task in awaiting_approval state.""" +def clickup_reset_all(ctx: dict | None = None) -> str: + """Delete all clickup task states and legacy active_ids from kv_store.""" db = ctx["db"] - key = f"clickup:task:{task_id}:state" - raw = db.kv_get(key) - if not raw: - return f"No tracked state found for task ID '{task_id}'." + states = _get_clickup_states(db) + count = 0 + for task_id in states: + db.kv_delete(f"clickup:task:{task_id}:state") + count += 1 - try: - state = json.loads(raw) - except json.JSONDecodeError: - return f"Corrupted state data for task '{task_id}'." + # Also clean up legacy active_ids key + if db.kv_get("clickup:active_task_ids"): + db.kv_delete("clickup:active_task_ids") - if state.get("state") != "awaiting_approval": - current = state.get("state") - return f"Task '{task_id}' is in state '{current}', not 'awaiting_approval'. Cannot decline." - - state["state"] = "declined" - db.kv_set(key, json.dumps(state)) - return f"Task '{state.get('clickup_task_name', task_id)}' has been declined." + return ( + f"Cleared {count} task state(s) from tracking. Next poll will re-discover eligible tasks." + ) diff --git a/cheddahbot/tools/delegate.py b/cheddahbot/tools/delegate.py index 91318f6..f598874 100644 --- a/cheddahbot/tools/delegate.py +++ b/cheddahbot/tools/delegate.py @@ -48,9 +48,7 @@ def delegate_task(task_description: str, ctx: dict | None = None) -> str: ), category="system", ) -def delegate_to_agent( - agent_name: str, task_description: str, ctx: dict | None = None -) -> str: +def delegate_to_agent(agent_name: str, task_description: str, ctx: dict | None = None) -> str: """Delegate a task to another agent by name.""" if not ctx or "agent_registry" not in ctx: return "Error: delegate_to_agent requires agent_registry in context." @@ -71,7 +69,9 @@ def delegate_to_agent( log.info( "Delegating to agent '%s' (depth %d): %s", - agent_name, depth + 1, task_description[:100], + agent_name, + depth + 1, + task_description[:100], ) _delegation_depth.value = depth + 1 diff --git a/tests/test_clickup.py b/tests/test_clickup.py index 205bd9d..31b3fc4 100644 --- a/tests/test_clickup.py +++ b/tests/test_clickup.py @@ -72,6 +72,38 @@ class TestClickUpTaskParsing: task = ClickUpTask.from_api(data) assert task.description == "" + def test_parses_due_date(self): + data = { + "id": "x", + "name": "Test", + "status": {"status": "open", "type": "open"}, + "due_date": "1740000000000", + "custom_fields": [], + } + task = ClickUpTask.from_api(data) + assert task.due_date == "1740000000000" + + def test_due_date_defaults_empty_when_null(self): + data = { + "id": "x", + "name": "Test", + "status": {"status": "open", "type": "open"}, + "due_date": None, + "custom_fields": [], + } + task = ClickUpTask.from_api(data) + assert task.due_date == "" + + def test_due_date_defaults_empty_when_missing(self): + data = { + "id": "x", + "name": "Test", + "status": {"status": "open", "type": "open"}, + "custom_fields": [], + } + task = ClickUpTask.from_api(data) + assert task.due_date == "" + def test_dropdown_resolved_by_id_fallback(self): """When orderindex doesn't match, fall back to id matching.""" data = { @@ -333,3 +365,72 @@ class TestClickUpClient: assert len(tasks) == 1 assert tasks[0].id == "t_x" client.close() + + @respx.mock + def test_get_tasks_with_due_date_and_custom_fields(self): + """Verify due_date_lt and custom_fields are passed as query params.""" + route = respx.get(f"{BASE_URL}/list/list_1/task").mock( + return_value=httpx.Response(200, json={"tasks": []}) + ) + + client = ClickUpClient(api_token="pk_test") + client.get_tasks( + "list_1", + statuses=["to do"], + due_date_lt=1740000000000, + custom_fields='[{"field_id":"cf_1","operator":"ANY","value":["opt_1"]}]', + ) + + request = route.calls.last.request + url_str = str(request.url) + assert "due_date_lt=1740000000000" in url_str + assert "custom_fields=" in url_str + client.close() + + @respx.mock + def test_discover_field_filter_found(self): + respx.get(f"{BASE_URL}/list/list_1/field").mock( + return_value=httpx.Response( + 200, + json={ + "fields": [ + { + "id": "cf_wc", + "name": "Work Category", + "type": "drop_down", + "type_config": { + "options": [ + {"id": "opt_pr", "name": "Press Release", "orderindex": 0}, + {"id": "opt_lb", "name": "Link Building", "orderindex": 1}, + ] + }, + }, + {"id": "cf_other", "name": "Company", "type": "short_text"}, + ] + }, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.discover_field_filter("list_1", "Work Category") + + assert result is not None + assert result["field_id"] == "cf_wc" + assert result["options"]["Press Release"] == "opt_pr" + assert result["options"]["Link Building"] == "opt_lb" + client.close() + + @respx.mock + def test_discover_field_filter_not_found(self): + respx.get(f"{BASE_URL}/list/list_1/field").mock( + return_value=httpx.Response( + 200, + json={"fields": [{"id": "cf_other", "name": "Company", "type": "short_text"}]}, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.discover_field_filter("list_1", "Work Category") + + assert result is None + client.close() diff --git a/tests/test_clickup_tools.py b/tests/test_clickup_tools.py index cf33339..d95c7d5 100644 --- a/tests/test_clickup_tools.py +++ b/tests/test_clickup_tools.py @@ -1,13 +1,13 @@ -"""Tests for the ClickUp chat tools (state machine transitions).""" +"""Tests for the ClickUp chat tools.""" from __future__ import annotations import json from cheddahbot.tools.clickup_tool import ( - clickup_approve_task, - clickup_decline_task, clickup_list_tasks, + clickup_reset_all, + clickup_reset_task, clickup_task_status, ) @@ -100,58 +100,48 @@ class TestClickupTaskStatus: assert "/data/pr1.txt" in result -class TestClickupApproveTask: - """Approval is the gate between 'discovered' and 'executing'. - If this breaks, tasks requiring approval can never run.""" +class TestClickupResetTask: + def test_resets_failed_task(self, tmp_db): + _seed_task(tmp_db, "f1", "failed") - def test_approves_awaiting_task(self, tmp_db): - _seed_task(tmp_db, "a1", "awaiting_approval") + result = clickup_reset_task(task_id="f1", ctx=_make_ctx(tmp_db)) - result = clickup_approve_task(task_id="a1", ctx=_make_ctx(tmp_db)) + assert "cleared" in result.lower() + assert tmp_db.kv_get("clickup:task:f1:state") is None - assert "approved" in result.lower() + def test_resets_completed_task(self, tmp_db): + _seed_task(tmp_db, "c1", "completed") - # Verify state changed in DB - raw = tmp_db.kv_get("clickup:task:a1:state") - state = json.loads(raw) - assert state["state"] == "approved" + result = clickup_reset_task(task_id="c1", ctx=_make_ctx(tmp_db)) - def test_rejects_non_awaiting_task(self, tmp_db): - _seed_task(tmp_db, "a1", "executing") - - result = clickup_approve_task(task_id="a1", ctx=_make_ctx(tmp_db)) - - assert "Cannot approve" in result - - # State should be unchanged - raw = tmp_db.kv_get("clickup:task:a1:state") - state = json.loads(raw) - assert state["state"] == "executing" + assert "cleared" in result.lower() + assert tmp_db.kv_get("clickup:task:c1:state") is None def test_unknown_task(self, tmp_db): - result = clickup_approve_task(task_id="nope", ctx=_make_ctx(tmp_db)) - assert "No tracked state" in result + result = clickup_reset_task(task_id="nope", ctx=_make_ctx(tmp_db)) + assert "Nothing to reset" in result -class TestClickupDeclineTask: - def test_declines_awaiting_task(self, tmp_db): - _seed_task(tmp_db, "d1", "awaiting_approval") +class TestClickupResetAll: + def test_clears_all_states(self, tmp_db): + _seed_task(tmp_db, "a1", "completed") + _seed_task(tmp_db, "a2", "failed") + _seed_task(tmp_db, "a3", "executing") - result = clickup_decline_task(task_id="d1", ctx=_make_ctx(tmp_db)) + result = clickup_reset_all(ctx=_make_ctx(tmp_db)) - assert "declined" in result.lower() + assert "3" in result + assert tmp_db.kv_get("clickup:task:a1:state") is None + assert tmp_db.kv_get("clickup:task:a2:state") is None + assert tmp_db.kv_get("clickup:task:a3:state") is None - raw = tmp_db.kv_get("clickup:task:d1:state") - state = json.loads(raw) - assert state["state"] == "declined" + def test_clears_legacy_active_ids(self, tmp_db): + tmp_db.kv_set("clickup:active_task_ids", json.dumps(["a1", "a2"])) - def test_rejects_non_awaiting_task(self, tmp_db): - _seed_task(tmp_db, "d1", "completed") + clickup_reset_all(ctx=_make_ctx(tmp_db)) - result = clickup_decline_task(task_id="d1", ctx=_make_ctx(tmp_db)) + assert tmp_db.kv_get("clickup:active_task_ids") is None - assert "Cannot decline" in result - - def test_unknown_task(self, tmp_db): - result = clickup_decline_task(task_id="nope", ctx=_make_ctx(tmp_db)) - assert "No tracked state" in result + def test_empty_returns_zero(self, tmp_db): + result = clickup_reset_all(ctx=_make_ctx(tmp_db)) + assert "0" in result