Reformat code and update ClickUp tools to reset pattern
- Ruff format: consistent dict/call wrapping in agent.py, db.py, skills.py, delegate.py - Replace clickup_approve_task/clickup_decline_task with clickup_reset_task/clickup_reset_all (simpler state machine) - Add kv_delete() method to Database - Add due_date and field filter tests to test_clickup.py - Update test_clickup_tools.py for new reset tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>cora-start
parent
a1fc5a7c0f
commit
916bec8c0e
|
|
@ -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({
|
||||
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({
|
||||
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({
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue