From 2de67213f8938038393d18912b912a7af9f0d0a2 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 10 Mar 2026 23:17:38 +0100 Subject: [PATCH] rename from checkpoint to timeline agent --- AI_REFACTOR_PLAN.md | 18 +-- BACKEND_PLAN.md | 8 +- README.md | 10 +- V3_MIGRATION_PLAN.md | 8 +- alembic/versions/002_seed_plugins.py | 4 +- app/agents/__init__.py | 4 +- ...{checkpoint_agent.py => timeline_agent.py} | 58 +++++----- app/api/routes/agent_setup.py | 4 +- app/core/agent_runner.py | 4 +- app/core/execution_plan.py | 8 +- app/core/output_formatter.py | 6 +- app/marketplace/plugin_review.py | 4 +- app/schemas.py | 4 +- tests/conftest.py | 4 +- tests/test_agents.py | 108 +++++++++--------- tests/test_execution_plan.py | 2 +- tests/test_orchestrator_v3.py | 8 +- tests/test_output_formatter.py | 6 +- tests/test_schemas_v3.py | 4 +- 19 files changed, 136 insertions(+), 136 deletions(-) rename app/agents/{checkpoint_agent.py => timeline_agent.py} (61%) diff --git a/AI_REFACTOR_PLAN.md b/AI_REFACTOR_PLAN.md index ac46d5e..fa5354c 100644 --- a/AI_REFACTOR_PLAN.md +++ b/AI_REFACTOR_PLAN.md @@ -69,7 +69,7 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern |---|---| | `tasks` | id, projectId, title, description, status (todo\|in_progress\|done), priority (high\|medium\|low), assignee (JSON array string), dueDate (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) | | `projects` | id, clientId, name, status (active\|archived), aiSummary, createdAt (ms) | -| `checkpoints` | id, projectId (required), title, date (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) | +| `timelines` | id, projectId (required), title, date (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) | | `notes` | id, projectId, title, content (markdown), createdAt (ms), updatedAt (ms) | | `taskComments` | id, taskId, author, content, createdAt (ms) | | `clients` | id, parentId, name, industry, createdAt (ms) | @@ -141,11 +141,11 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern - `update_project(project_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation - `delete_project(project_id)`: `execute_on_client(action="delete", ...)` → return confirmation -- [x] **`app/agents/checkpoint_agent.py` (4 tools):** - - `list_checkpoints(project_id)`: `execute_on_client(action="select", table="checkpoints", filters={projectId})` → format + return - - `create_checkpoint(project_id, title, date, ...)`: `execute_on_client(action="insert", table="checkpoints", data={...})` → return confirmation + id - - `update_checkpoint(checkpoint_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation - - `delete_checkpoint(checkpoint_id)`: `execute_on_client(action="delete", ...)` → return confirmation +- [x] **`app/agents/timeline_agent.py` (4 tools):** + - `list_timelines(project_id)`: `execute_on_client(action="select", table="timelines", filters={projectId})` → format + return + - `create_timeline(project_id, title, date, ...)`: `execute_on_client(action="insert", table="timelines", data={...})` → return confirmation + id + - `update_timeline(timeline_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation + - `delete_timeline(timeline_id)`: `execute_on_client(action="delete", ...)` → return confirmation - [x] **`app/agents/note_agent.py` (5 tools):** - `list_notes(project_id)`: `execute_on_client(action="select", table="notes", filters={projectId})` → format + return @@ -154,7 +154,7 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern - `update_note(note_id, ...)`: build updates → `execute_on_client(action="update", ...)` → then vector_upsert for updated content → return confirmation - `delete_note(note_id)`: `execute_on_client(action="delete", ...)` → return confirmation -- **Files:** `app/agents/task_agent.py`, `app/agents/project_agent.py`, `app/agents/checkpoint_agent.py`, `app/agents/note_agent.py` +- **Files:** `app/agents/task_agent.py`, `app/agents/project_agent.py`, `app/agents/timeline_agent.py`, `app/agents/note_agent.py` - **Outcome:** All 23 tools query real user data via WS. LLM sees actual rows, not action descriptors. ### Step B.3 — Bidirectional WebSocket handler @@ -282,7 +282,7 @@ Cloud Agent: - `device_id` str — identifies which Electron install this config belongs to - `name` str - `directory_paths` JSON — list of absolute paths on the device - - `data_types` JSON — which tables to extract to: `["tasks", "notes", "checkpoints", "projects"]` + - `data_types` JSON — which tables to extract to: `["tasks", "notes", "timelines", "projects"]` - `prompt_template` text — user-configured via Chatbot Journey - `file_extensions` JSON — e.g. `[".eml", ".txt", ".pdf", ".md"]` - `schedule_cron` str — e.g. `"0 */6 * * *"` (every 6h) @@ -429,7 +429,7 @@ Cloud Agent: - `POST /api/v1/agents/journey/message`: - Body: `{ session_id, message }` - AI processes user's answer, asks follow-up questions (max 5 turns) - - System prompt: "You are configuring a data extraction agent for a freelancer. Ask about file format, what data to extract (tasks, notes, checkpoints), naming conventions, priority rules, and any special mapping. After 3-5 questions, generate a detailed prompt_template." + - System prompt: "You are configuring a data extraction agent for a freelancer. Ask about file format, what data to extract (tasks, notes, timelines), naming conventions, priority rules, and any special mapping. After 3-5 questions, generate a detailed prompt_template." - When AI determines enough context: `{ session_id, message: "Here's your configuration...", done: true, prompt_template: "..." }` - The `prompt_template` is a structured instruction for the extraction LLM (e.g. "Extract tasks from email. Subject becomes task title. If body contains 'urgent' or 'ASAP', set priority to 'high'. Extract due dates if mentioned.") - **Electron note:** `toCamelCase` converts the response → Electron reads `promptTemplate` from the final message and auto-fills the agent config panel. User clicks "Save & apply" which calls `agent.local.update` / `agent.cloud.update` tRPC mutation. diff --git a/BACKEND_PLAN.md b/BACKEND_PLAN.md index 8ed7dd8..aac66d1 100644 --- a/BACKEND_PLAN.md +++ b/BACKEND_PLAN.md @@ -201,9 +201,9 @@ adiuva-api/ - Tools (8): `list_tasks(project_id, status, search, order_by)`, `create_task(title, description, status, priority, assignees, due_date, project_id, is_ai_suggested, is_approved)`, `update_task(task_id, ...)`, `delete_task(task_id)`, `list_tasks_due_today()`, `list_task_comments(task_id)`, `add_task_comment(task_id, author, content)`, `delete_task_comment(comment_id)` - status: `todo|in_progress|done`; priority: `high|medium|low`; assignees: JSON-encoded string; due_date: ms timestamp - Accepts flexible context; sentinel `-1` for optional integer update fields -- [x] `app/agents/checkpoint_agent.py` — `@registry.register`: - - Description: "Manages project checkpoints (milestones): list, create, update, delete" - - Tools (4): `list_checkpoints(project_id)`, `create_checkpoint(project_id, title, date, is_ai_suggested, is_approved)`, `update_checkpoint(checkpoint_id, ...)`, `delete_checkpoint(checkpoint_id)` +- [x] `app/agents/timeline_agent.py` — `@registry.register`: + - Description: "Manages project timelines (milestones): list, create, update, delete" + - Tools (4): `list_timelines(project_id)`, `create_timeline(project_id, title, date, is_ai_suggested, is_approved)`, `update_timeline(timeline_id, ...)`, `delete_timeline(timeline_id)` - `project_id` is required for create; date is a ms timestamp; supports AI-suggestion + approval workflow - [x] `app/agents/project_agent.py` — `@registry.register`: - Description: "Manages projects: list, get, create, update, archive, delete" @@ -215,7 +215,7 @@ adiuva-api/ - content is Markdown; `get_note` should be called before update to preserve existing content - [x] `app/agents/__init__.py`: imports all four agent modules to trigger `@registry.register` decorators - [x] Unit tests per agent with mocked LLM (registration, names, tool counts, handle(), direct tool invocation) -- **Outcome:** Four domain-specific agents matching the UI data model (Tasks, Checkpoints, Projects, Notes), all registered and tested. +- **Outcome:** Four domain-specific agents matching the UI data model (Tasks, Timelines, Projects, Notes), all registered and tested. ### Step 7 — Storage Layer ✅ - [x] `app/storage/blob_store.py`: diff --git a/README.md b/README.md index bc8a849..19da6ea 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Adiuva Cloud API is the FastAPI backend that powers the **Adiuva Electron deskto ## Key Features 1. **LLM-powered orchestration** — GPT-4o-mini classifies user intent and routes to the appropriate domain agent. -2. **4 specialized AI agents** — Tasks (8 tools), Projects (6 tools), Checkpoints (4 tools), Notes (5 tools), all powered by GPT-4o via LangChain. +2. **4 specialized AI agents** — Tasks (8 tools), Projects (6 tools), Timelines (4 tools), Notes (5 tools), all powered by GPT-4o via LangChain. 3. **Execution plans & playbooks** — Server-side prompt template registry; clients receive only opaque template IDs, never raw prompts. 4. **E2E encrypted cloud storage** — The backend never decrypts user data; SHA-256 checksum verification uses constant-time comparison to prevent timing attacks. 5. **Cloud vector store** — Pinecone or Qdrant with user-isolated namespaces and encrypted blob payloads. @@ -449,7 +449,7 @@ The agent system uses a registry pattern with LangChain tool-calling agents powe |---|---|---|---| | **TaskAgent** | `task_agent` | 8 | Full task and comment CRUD. Status: `todo` / `in_progress` / `done`. Priority: `high` / `medium` / `low`. Tools: `list_tasks`, `create_task`, `update_task`, `delete_task`, `list_tasks_due_today`, `list_task_comments`, `add_task_comment`, `delete_task_comment` | | **ProjectAgent** | `project_agent` | 6 | Project lifecycle management. Status: `active` / `archived`. Prefers archiving over deletion. Tools: `list_projects`, `list_all_projects`, `get_project`, `create_project`, `update_project`, `delete_project` | -| **CheckpointAgent** | `checkpoint_agent` | 4 | Project milestones. Requires `project_id` for creation. Supports AI-suggestion and approval workflows. Tools: `list_checkpoints`, `create_checkpoint`, `update_checkpoint`, `delete_checkpoint` | +| **TimelineAgent** | `timeline_agent` | 4 | Project milestones. Requires `project_id` for creation. Supports AI-suggestion and approval workflows. Tools: `list_timelines`, `create_timeline`, `update_timeline`, `delete_timeline` | | **NoteAgent** | `note_agent` | 5 | Markdown note management. Optionally linked to projects. Tools: `list_notes`, `get_note`, `create_note`, `update_note`, `delete_note` | All agents use the model configured by `LLM_MODEL` (default: GPT-4o) with `temperature=0` via LiteLLM. Tools return JSON action descriptors that the Electron client interprets and applies locally. @@ -504,7 +504,7 @@ Source: `app/core/orchestrator.py`, `app/core/execution_plan.py` ### Built-in Templates (6) -`tpl_task_agent_default`, `tpl_checkpoint_agent_default`, `tpl_project_agent_default`, `tpl_note_agent_default`, `tpl_task_extract_from_project`, `tpl_note_weekly_summary` +`tpl_task_agent_default`, `tpl_timeline_agent_default`, `tpl_project_agent_default`, `tpl_note_agent_default`, `tpl_task_extract_from_project`, `tpl_note_weekly_summary` ### Built-in Playbooks (2) @@ -643,7 +643,7 @@ Source: `app/marketplace/` - Plugin ID must match `^[a-z0-9-]+$` - Permissions must be from the allowed set only - No binary blobs in the manifest -- **Allowed permissions:** `read:tasks`, `write:tasks`, `read:projects`, `write:projects`, `read:notes`, `write:notes`, `read:checkpoints`, `write:checkpoints`, `read:calendar`, `write:calendar` +- **Allowed permissions:** `read:tasks`, `write:tasks`, `read:projects`, `write:projects`, `read:notes`, `write:notes`, `read:timelines`, `write:timelines`, `read:calendar`, `write:calendar` - `get_pending(db)` — Lists plugins awaiting review. - `submit_review(db, plugin_id, reviewer_id, decision, notes)` — Records the review decision. @@ -734,7 +734,7 @@ adiuva-api/ │ ├── agents/ # LLM-powered domain agents │ │ ├── task_agent.py # Task & comment CRUD (8 tools) │ │ ├── project_agent.py # Project lifecycle (6 tools) -│ │ ├── checkpoint_agent.py # Milestones (4 tools) +│ │ ├── timeline_agent.py # Milestones (4 tools) │ │ └── note_agent.py # Markdown notes (5 tools) │ │ │ ├── core/ # Orchestration engine diff --git a/V3_MIGRATION_PLAN.md b/V3_MIGRATION_PLAN.md index aec063c..fa3eb3c 100644 --- a/V3_MIGRATION_PLAN.md +++ b/V3_MIGRATION_PLAN.md @@ -169,7 +169,7 @@ Supported entity types (matching Electron component types): - `task` — TaskRow component (`TaskItem`: id, title, status, priority, assignee, dueDate, projectId, ...) - `project` — Project card (id, name, clientId, status) - `note` — Note card (id, title, createdAt, projectId) -- `checkpoint` — Checkpoint card (GanttCheckpoint: id, title, date, projectId, isAiSuggested, isApproved) +- `timeline` — Timeline card (GanttTimeline: id, title, date, projectId, isAiSuggested, isApproved) **Table block** — buffered, validated: ```json @@ -178,7 +178,7 @@ Supported entity types (matching Electron component types): **Timeline block** — buffered, validated (renders via GanttChart component): ```json -{ "type": "timeline", "checkpoints": [{ "id": "...", "title": "...", "date": 1234567890 }] } +{ "type": "timeline", "timelines": [{ "id": "...", "title": "...", "date": 1234567890 }] } ``` ### Changes @@ -192,13 +192,13 @@ Supported entity types (matching Electron component types): - `chart` -> buffers until JSON complete, validates `chartType` against allowed set, yields `WsStreamBlock` - `entity_ref` -> looks up data from `agent.tool_results`, serializes full entity, yields `WsStreamBlock` - `table` -> buffers, validates headers/rows structure, yields `WsStreamBlock` - - `timeline` -> buffers, validates checkpoint objects, yields `WsStreamBlock` + - `timeline` -> buffers, validates timeline objects, yields `WsStreamBlock` - Invalid blocks are logged and skipped (never crash the stream) - `FloatingFormatter`: - Receives `agent_name` from orchestrator - Maps agent name to domain (deterministic, by code — no LLM): - `task_agent` -> `"tasks"` - - `checkpoint_agent` -> `"checkpoints"` + - `timeline_agent` -> `"timelines"` - `note_agent` -> `"notes"` - `project_agent` -> `"projects"` - Yields `WsFloatingDomain` immediately diff --git a/alembic/versions/002_seed_plugins.py b/alembic/versions/002_seed_plugins.py index 0fad36a..e38fcaa 100644 --- a/alembic/versions/002_seed_plugins.py +++ b/alembic/versions/002_seed_plugins.py @@ -37,12 +37,12 @@ _SEED_PLUGINS = [ { "id": "plugin-slack-notify", "name": "Slack Notifier", - "description": "Post task and checkpoint updates to Slack channels.", + "description": "Post task and timeline updates to Slack channels.", "version": "1.2.0", "author_name": "Adiuva", "category": "communication", "price_cents": 499, - "permissions": json.dumps(["read:tasks", "read:checkpoints"]), + "permissions": json.dumps(["read:tasks", "read:timelines"]), "status": "approved", "s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip", "install_count": 0, diff --git a/app/agents/__init__.py b/app/agents/__init__.py index a511527..6a202c1 100644 --- a/app/agents/__init__.py +++ b/app/agents/__init__.py @@ -1,5 +1,5 @@ """Import all agent modules to trigger @registry.register decorators.""" -from app.agents import checkpoint_agent, note_agent, project_agent, task_agent +from app.agents import timeline_agent, note_agent, project_agent, task_agent -__all__ = ["checkpoint_agent", "note_agent", "project_agent", "task_agent"] +__all__ = ["timeline_agent", "note_agent", "project_agent", "task_agent"] diff --git a/app/agents/checkpoint_agent.py b/app/agents/timeline_agent.py similarity index 61% rename from app/agents/checkpoint_agent.py rename to app/agents/timeline_agent.py index 91d4f56..6e85357 100644 --- a/app/agents/checkpoint_agent.py +++ b/app/agents/timeline_agent.py @@ -1,4 +1,4 @@ -"""Checkpoint agent — project milestone management (list, create, update, delete).""" +"""Timeline agent — project milestone management (list, create, update, delete).""" from __future__ import annotations @@ -13,43 +13,43 @@ from app.core.llm import get_llm from app.core.ws_context import execute_on_client _SYSTEM_PROMPT = ( - "You are a project checkpoint assistant. Checkpoints are milestone dates that\n" + "You are a project timeline assistant. Timelines are milestone dates that\n" "track progress on a project — they are not calendar events.\n\n" "Rules:\n" " - project_id is REQUIRED for every create; confirm with the user if unknown\n" " - date is a Unix timestamp in milliseconds; convert human-readable dates\n" - " - is_ai_suggested: 1 when proactively proposing a checkpoint, 0 otherwise\n" + " - is_ai_suggested: 1 when proactively proposing a timeline, 0 otherwise\n" " - is_approved: 0 until the user explicitly confirms; then 1\n" - " - For update_checkpoint, use -1 for integer fields you do not want to change\n" - " - Listing without a project_id returns all checkpoints across projects\n" + " - For update_timeline, use -1 for integer fields you do not want to change\n" + " - Listing without a project_id returns all timelines across projects\n" " - Always echo the title and formatted date in your confirmation." ) @tool -async def list_checkpoints(project_id: str = "") -> str: - """List checkpoints. Provide project_id to scope to a specific project.""" +async def list_timelines(project_id: str = "") -> str: + """List timelines. Provide project_id to scope to a specific project.""" result = await execute_on_client( action="select", - table="checkpoints", + table="timelines", filters={"projectId": project_id or None}, ) rows = result.get("rows", []) if not rows: - return "No checkpoints found." + return "No timelines found." lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows] - return f"Found {len(rows)} checkpoint(s):\n" + "\n".join(lines) + return f"Found {len(rows)} timeline(s):\n" + "\n".join(lines) @tool -async def create_checkpoint( +async def create_timeline( project_id: str, title: str, date: int, is_ai_suggested: int = 0, is_approved: int = 0, ) -> str: - """Create a project checkpoint (milestone). + """Create a project timeline (milestone). project_id: REQUIRED UUID of the parent project title: descriptive name for the milestone date: Unix timestamp in milliseconds @@ -58,7 +58,7 @@ async def create_checkpoint( """ result = await execute_on_client( action="insert", - table="checkpoints", + table="timelines", data={ "projectId": project_id, "title": title, @@ -68,18 +68,18 @@ async def create_checkpoint( }, ) row = result["row"] - return f"Checkpoint created: '{row['title']}' (id: {row['id']}, date: {row['date']})" + return f"Timeline created: '{row['title']}' (id: {row['id']}, date: {row['date']})" @tool -async def update_checkpoint( - checkpoint_id: str, +async def update_timeline( + timeline_id: str, title: str = "", date: int = -1, is_approved: int = -1, ) -> str: - """Update a checkpoint. Only pass fields that should change. - checkpoint_id: UUID of the checkpoint (required) + """Update a timeline. Only pass fields that should change. + timeline_id: UUID of the timeline (required) date: -1 means unchanged; any other value sets the new date (ms timestamp) is_approved: -1 means unchanged; 0 or 1 sets the approval state """ @@ -92,30 +92,30 @@ async def update_checkpoint( updates["isApproved"] = is_approved result = await execute_on_client( action="update", - table="checkpoints", - data={"id": checkpoint_id, "updates": updates}, + table="timelines", + data={"id": timeline_id, "updates": updates}, ) row = result["row"] - return f"Checkpoint updated: '{row['title']}' (id: {row['id']})" + return f"Timeline updated: '{row['title']}' (id: {row['id']})" @tool -async def delete_checkpoint(checkpoint_id: str) -> str: - """Delete a checkpoint permanently by its UUID.""" - await execute_on_client(action="delete", table="checkpoints", data={"id": checkpoint_id}) - return f"Checkpoint {checkpoint_id} deleted." +async def delete_timeline(timeline_id: str) -> str: + """Delete a timeline permanently by its UUID.""" + await execute_on_client(action="delete", table="timelines", data={"id": timeline_id}) + return f"Timeline {timeline_id} deleted." @registry.register -class CheckpointAgent(ChatAgent): +class TimelineAgent(ChatAgent): def get_name(self) -> str: - return "checkpoint_agent" + return "timeline_agent" def get_description(self) -> str: - return "Manages project checkpoints (milestones): list, create, update, delete" + return "Manages project timelines (milestones): list, create, update, delete" def get_tools(self) -> list[Any]: - return [list_checkpoints, create_checkpoint, update_checkpoint, delete_checkpoint] + return [list_timelines, create_timeline, update_timeline, delete_timeline] async def handle(self, query: str, context: dict[str, Any]) -> str: llm = get_llm() diff --git a/app/api/routes/agent_setup.py b/app/api/routes/agent_setup.py index 2cc755a..e78bf75 100644 --- a/app/api/routes/agent_setup.py +++ b/app/api/routes/agent_setup.py @@ -107,7 +107,7 @@ and produce a detailed prompt_template that a separate AI will use as its instru Ask concise, focused questions one at a time. Cover these topics (not necessarily in this order): 1. The type and format of the source content. - 2. Which data types to extract: tasks, notes, checkpoints, and/or projects. + 2. Which data types to extract: tasks, notes, timelines, and/or projects. 3. How fields should be mapped (e.g. email subject → task title). 4. Priority or status rules (e.g. "urgent" keyword → high priority). 5. Any special handling, date extraction, or exclusions. @@ -121,7 +121,7 @@ these exact markers on their own lines: The prompt_template must be a self-contained instruction for an AI that receives a document/email/message \ and must return a JSON array of records in this shape: - [{{ "table": "", "data": {{ }} }}, ...] + [{{ "table": "", "data": {{ }} }}, ...] Rules for the generated template: - Be explicit about field names (camelCase: title, status, priority, dueDate, projectId, content, etc.). diff --git a/app/core/agent_runner.py b/app/core/agent_runner.py index b8b8242..0d25f65 100644 --- a/app/core/agent_runner.py +++ b/app/core/agent_runner.py @@ -53,7 +53,7 @@ _INSERT_TIMEOUT: int = 30 # ── Allowed tables & extraction schema hints ─────────────────────────────── _ALLOWED_TABLES: frozenset[str] = frozenset( - {"tasks", "notes", "checkpoints", "projects", "taskComments"} + {"tasks", "notes", "timelines", "projects", "taskComments"} ) # Field descriptions fed to the extraction LLM as concise schema references. @@ -65,7 +65,7 @@ _TABLE_SCHEMAS: dict[str, str] = { "assignee (JSON array string), dueDate (ms timestamp int), projectId (str)" ), "notes": "title (str, required), content (str, markdown), projectId (str)", - "checkpoints": ( + "timelines": ( "title (str, required), projectId (str, required), date (ms timestamp int)" ), "projects": "name (str, required), clientId (str)", diff --git a/app/core/execution_plan.py b/app/core/execution_plan.py index b763937..a98879f 100644 --- a/app/core/execution_plan.py +++ b/app/core/execution_plan.py @@ -159,9 +159,9 @@ def _register_builtin_templates() -> None: "list, and track tasks. Use correct status values (todo, in_progress, " "done) and priority values (high, medium, low) from the workspace model." ), - "tpl_checkpoint_agent_default": ( - "You are a project checkpoint assistant. Help the user create and manage " - "milestone checkpoints on their projects. Every checkpoint requires a " + "tpl_timeline_agent_default": ( + "You are a project timeline assistant. Help the user create and manage " + "milestone timelines on their projects. Every timeline requires a " "project_id and a date expressed as a Unix timestamp in milliseconds." ), "tpl_project_agent_default": ( @@ -182,7 +182,7 @@ def _register_builtin_templates() -> None: "tpl_note_weekly_summary": ( "Generate a weekly project summary note from the provided workspace data. " "Include: tasks completed this week, tasks due soon, active projects, " - "and upcoming checkpoints. Format the output as clean Markdown." + "and upcoming timelines. Format the output as clean Markdown." ), } for tid, text in _tpls.items(): diff --git a/app/core/output_formatter.py b/app/core/output_formatter.py index 996b3fd..a8e44fb 100644 --- a/app/core/output_formatter.py +++ b/app/core/output_formatter.py @@ -27,7 +27,7 @@ _VALID_CHART_TYPES = {"area", "bar", "line", "pie", "radar", "radial"} # Map agent name → floating domain _AGENT_DOMAIN: dict[str, str] = { "task_agent": "tasks", - "checkpoint_agent": "checkpoints", + "timeline_agent": "timelines", "note_agent": "notes", "project_agent": "projects", } @@ -171,8 +171,8 @@ class HomeFormatter: ) if block_type == "timeline": - if not isinstance(obj.get("checkpoints"), list): - logger.warning("HomeFormatter: timeline missing checkpoints — skipping") + if not isinstance(obj.get("timelines"), list): + logger.warning("HomeFormatter: timeline missing timelines — skipping") return None return WsStreamBlock( request_id=self.request_id, diff --git a/app/marketplace/plugin_review.py b/app/marketplace/plugin_review.py index 5e4aeec..28a5764 100644 --- a/app/marketplace/plugin_review.py +++ b/app/marketplace/plugin_review.py @@ -29,8 +29,8 @@ ALLOWED_PERMISSIONS: frozenset[str] = frozenset( "write:projects", "read:notes", "write:notes", - "read:checkpoints", - "write:checkpoints", + "read:timelines", + "write:timelines", "read:calendar", "write:calendar", } diff --git a/app/schemas.py b/app/schemas.py index 2ca50e9..f3a281b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -268,7 +268,7 @@ class WsAgentComplete(BaseModel): class WsFloatingScope(BaseModel): """Scope for a floating request — narrows the agent to a specific entity.""" - type: Literal["task", "project", "note", "checkpoint"] + type: Literal["task", "project", "note", "timeline"] id: str | None = None @@ -325,7 +325,7 @@ class WsFloatingDomain(BaseModel): type: Literal[WsFrameType.floating_domain] = WsFrameType.floating_domain request_id: str - domain: Literal["tasks", "checkpoints", "notes", "projects"] + domain: Literal["tasks", "timelines", "notes", "projects"] # ── Agent Catalog ───────────────────────────────────────────────────── diff --git a/tests/conftest.py b/tests/conftest.py index f3a1cbd..74244aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -129,12 +129,12 @@ _SEED_PLUGINS = [ Plugin( id="plugin-slack-notify", name="Slack Notifier", - description="Post task and checkpoint updates to Slack channels.", + description="Post task and timeline updates to Slack channels.", version="1.2.0", author_name="Adiuva", category="communication", price_cents=499, - permissions=json.dumps(["read:tasks", "read:checkpoints"]), + permissions=json.dumps(["read:tasks", "read:timelines"]), status="approved", s3_package_key="plugins/plugin-slack-notify/1.2.0/package.zip", install_count=0, diff --git a/tests/test_agents.py b/tests/test_agents.py index e31813e..4023232 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import app.agents # noqa: F401 — triggers @registry.register decorators -from app.agents.checkpoint_agent import CheckpointAgent +from app.agents.timeline_agent import TimelineAgent from app.agents.note_agent import NoteAgent from app.agents.project_agent import ProjectAgent from app.agents.task_agent import TaskAgent @@ -110,12 +110,12 @@ class TestAgentRegistration: def test_all_agents_registered(self) -> None: names = {a["name"] for a in registry.list_agents()} assert { - "task_agent", "checkpoint_agent", "project_agent", "note_agent" + "task_agent", "timeline_agent", "project_agent", "note_agent" }.issubset(names) def test_registry_returns_correct_types(self) -> None: assert isinstance(registry.get("task_agent"), TaskAgent) - assert isinstance(registry.get("checkpoint_agent"), CheckpointAgent) + assert isinstance(registry.get("timeline_agent"), TimelineAgent) assert isinstance(registry.get("project_agent"), ProjectAgent) assert isinstance(registry.get("note_agent"), NoteAgent) @@ -336,94 +336,94 @@ class TestTaskAgentTools: assert "c1" in result -# ── CheckpointAgent ─────────────────────────────────────────────────── +# ── TimelineAgent ─────────────────────────────────────────────────── -class TestCheckpointAgent: +class TestTimelineAgent: def test_name(self) -> None: - assert CheckpointAgent().get_name() == "checkpoint_agent" + assert TimelineAgent().get_name() == "timeline_agent" def test_description(self) -> None: - assert CheckpointAgent().get_description() == "Manages project checkpoints (milestones): list, create, update, delete" + assert TimelineAgent().get_description() == "Manages project timelines (milestones): list, create, update, delete" def test_get_tools_count(self) -> None: - assert len(CheckpointAgent().get_tools()) == 4 + assert len(TimelineAgent().get_tools()) == 4 def test_tool_names(self) -> None: - names = {t.name for t in CheckpointAgent().get_tools()} - assert names == {"list_checkpoints", "create_checkpoint", "update_checkpoint", "delete_checkpoint"} + names = {t.name for t in TimelineAgent().get_tools()} + assert names == {"list_timelines", "create_timeline", "update_timeline", "delete_timeline"} @pytest.mark.asyncio async def test_handle_no_tool_calls(self) -> None: - with patch("app.agents.checkpoint_agent.get_llm") as mock_cls: - mock_cls.return_value = _mock_llm("No checkpoints found.") - result = await CheckpointAgent().handle("list checkpoints", {}) - assert result == "No checkpoints found." + with patch("app.agents.timeline_agent.get_llm") as mock_cls: + mock_cls.return_value = _mock_llm("No timelines found.") + result = await TimelineAgent().handle("list timelines", {}) + assert result == "No timelines found." @pytest.mark.asyncio async def test_handle_with_create_tool_call(self) -> None: - with patch("app.agents.checkpoint_agent.get_llm") as mock_cls: + with patch("app.agents.timeline_agent.get_llm") as mock_cls: mock_cls.return_value = _mock_llm_with_tool_call( - "create_checkpoint", + "create_timeline", {"project_id": "p1", "title": "MVP Launch", "date": 1700000000000}, - "Checkpoint 'MVP Launch' created.", + "Timeline 'MVP Launch' created.", ) - result = await CheckpointAgent().handle("add MVP checkpoint", {}) - assert result == "Checkpoint 'MVP Launch' created." + result = await TimelineAgent().handle("add MVP timeline", {}) + assert result == "Timeline 'MVP Launch' created." @pytest.mark.asyncio async def test_handle_accepts_empty_context(self) -> None: - with patch("app.agents.checkpoint_agent.get_llm") as mock_cls: + with patch("app.agents.timeline_agent.get_llm") as mock_cls: mock_cls.return_value = _mock_llm("Done.") - result = await CheckpointAgent().handle("show milestones", {}) + result = await TimelineAgent().handle("show milestones", {}) assert isinstance(result, str) -class TestCheckpointAgentTools: +class TestTimelineAgentTools: @pytest.mark.asyncio - async def test_list_checkpoints_no_project(self) -> None: - from app.agents.checkpoint_agent import list_checkpoints - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + async def test_list_timelines_no_project(self) -> None: + from app.agents.timeline_agent import list_timelines + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"rows": []} - result = await list_checkpoints.ainvoke({}) + result = await list_timelines.ainvoke({}) call_kwargs = m.call_args.kwargs assert call_kwargs["action"] == "select" - assert call_kwargs["table"] == "checkpoints" + assert call_kwargs["table"] == "timelines" assert call_kwargs["filters"]["projectId"] is None - assert result == "No checkpoints found." + assert result == "No timelines found." @pytest.mark.asyncio - async def test_list_checkpoints_with_project(self) -> None: - from app.agents.checkpoint_agent import list_checkpoints - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + async def test_list_timelines_with_project(self) -> None: + from app.agents.timeline_agent import list_timelines + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"rows": []} - await list_checkpoints.ainvoke({"project_id": "p1"}) + await list_timelines.ainvoke({"project_id": "p1"}) assert m.call_args.kwargs["filters"]["projectId"] == "p1" @pytest.mark.asyncio - async def test_create_checkpoint(self) -> None: - from app.agents.checkpoint_agent import create_checkpoint + async def test_create_timeline(self) -> None: + from app.agents.timeline_agent import create_timeline fake_row = {"id": "cp1", "title": "Beta release", "date": 1700000000000} - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"row": fake_row} - result = await create_checkpoint.ainvoke({ + result = await create_timeline.ainvoke({ "project_id": "p1", "title": "Beta release", "date": 1700000000000, }) call_kwargs = m.call_args.kwargs assert call_kwargs["action"] == "insert" - assert call_kwargs["table"] == "checkpoints" + assert call_kwargs["table"] == "timelines" assert call_kwargs["data"]["projectId"] == "p1" assert call_kwargs["data"]["title"] == "Beta release" assert call_kwargs["data"]["date"] == 1700000000000 assert "Beta release" in result @pytest.mark.asyncio - async def test_create_checkpoint_ai_suggested(self) -> None: - from app.agents.checkpoint_agent import create_checkpoint + async def test_create_timeline_ai_suggested(self) -> None: + from app.agents.timeline_agent import create_timeline fake_row = {"id": "cp1", "title": "Review", "date": 1700000000000} - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"row": fake_row} - await create_checkpoint.ainvoke({ + await create_timeline.ainvoke({ "project_id": "p1", "title": "Review", "date": 1700000000000, "is_ai_suggested": 1, }) call_kwargs = m.call_args.kwargs @@ -431,12 +431,12 @@ class TestCheckpointAgentTools: assert call_kwargs["data"]["isApproved"] == 0 @pytest.mark.asyncio - async def test_update_checkpoint_approve(self) -> None: - from app.agents.checkpoint_agent import update_checkpoint + async def test_update_timeline_approve(self) -> None: + from app.agents.timeline_agent import update_timeline fake_row = {"id": "c1", "title": "MVP", "isApproved": 1} - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"row": fake_row} - result = await update_checkpoint.ainvoke({"checkpoint_id": "c1", "is_approved": 1}) + result = await update_timeline.ainvoke({"timeline_id": "c1", "is_approved": 1}) call_kwargs = m.call_args.kwargs assert call_kwargs["action"] == "update" assert call_kwargs["data"]["id"] == "c1" @@ -444,23 +444,23 @@ class TestCheckpointAgentTools: assert "c1" in result @pytest.mark.asyncio - async def test_update_checkpoint_empty_updates(self) -> None: - from app.agents.checkpoint_agent import update_checkpoint + async def test_update_timeline_empty_updates(self) -> None: + from app.agents.timeline_agent import update_timeline fake_row = {"id": "c1", "title": "MVP"} - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"row": fake_row} - await update_checkpoint.ainvoke({"checkpoint_id": "c1"}) + await update_timeline.ainvoke({"timeline_id": "c1"}) assert m.call_args.kwargs["data"]["updates"] == {} @pytest.mark.asyncio - async def test_delete_checkpoint(self) -> None: - from app.agents.checkpoint_agent import delete_checkpoint - with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m: + async def test_delete_timeline(self) -> None: + from app.agents.timeline_agent import delete_timeline + with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m: m.return_value = {"deleted": True} - result = await delete_checkpoint.ainvoke({"checkpoint_id": "c1"}) + result = await delete_timeline.ainvoke({"timeline_id": "c1"}) call_kwargs = m.call_args.kwargs assert call_kwargs["action"] == "delete" - assert call_kwargs["table"] == "checkpoints" + assert call_kwargs["table"] == "timelines" assert call_kwargs["data"]["id"] == "c1" assert "c1" in result diff --git a/tests/test_execution_plan.py b/tests/test_execution_plan.py index f468177..06a2bfa 100644 --- a/tests/test_execution_plan.py +++ b/tests/test_execution_plan.py @@ -243,7 +243,7 @@ class TestPlanCache: class TestModuleSingletons: def test_template_registry_has_all_agent_defaults(self) -> None: - for agent in ("task_agent", "checkpoint_agent", "project_agent", "note_agent"): + for agent in ("task_agent", "timeline_agent", "project_agent", "note_agent"): assert template_registry.has(f"tpl_{agent}_default"), ( f"Missing template: tpl_{agent}_default" ) diff --git a/tests/test_orchestrator_v3.py b/tests/test_orchestrator_v3.py index cf9197d..fccb8ab 100644 --- a/tests/test_orchestrator_v3.py +++ b/tests/test_orchestrator_v3.py @@ -94,13 +94,13 @@ async def test_orchestrate_v3_uses_default_registry_when_none(): @pytest.mark.asyncio async def test_orchestrate_v3_get_called_with_agent_name(): - agent = _FixedAgent("checkpoint_agent") - reg = _make_registry("checkpoint_agent", agent) + agent = _FixedAgent("timeline_agent") + reg = _make_registry("timeline_agent", agent) - with patch("app.core.orchestrator.classify_intent", AsyncMock(return_value="checkpoint_agent")): + with patch("app.core.orchestrator.classify_intent", AsyncMock(return_value="timeline_agent")): await orchestrate_v3(user_id="u-2", message="schedule", context={}, reg=reg) - reg.get.assert_called_once_with("checkpoint_agent") + reg.get.assert_called_once_with("timeline_agent") # ── orchestrate_v3_stream ───────────────────────────────────────────── diff --git a/tests/test_output_formatter.py b/tests/test_output_formatter.py index 61a1f31..bfc5c1c 100644 --- a/tests/test_output_formatter.py +++ b/tests/test_output_formatter.py @@ -115,7 +115,7 @@ async def test_home_formatter_table_block(): @pytest.mark.asyncio async def test_home_formatter_timeline_block(): req_id = "req-7" - timeline_json = '{"type": "timeline", "checkpoints": [{"id": "c1", "title": "M1", "date": 123}]}' + timeline_json = '{"type": "timeline", "timelines": [{"id": "c1", "title": "M1", "date": 123}]}' formatter = HomeFormatter(request_id=req_id, tool_results=[]) frames = await collect(formatter, _stream(("task_agent", timeline_json))) @@ -156,11 +156,11 @@ async def test_floating_formatter_domain_emitted_first(): async def test_floating_formatter_text_only(): req_id = "pop-2" formatter = FloatingFormatter(request_id=req_id) - tokens = [("checkpoint_agent", ""), ("checkpoint_agent", "Summary")] + tokens = [("timeline_agent", ""), ("timeline_agent", "Summary")] frames = await collect(formatter, _stream(*tokens)) assert isinstance(frames[0], WsFloatingDomain) - assert frames[0].domain == "checkpoints" + assert frames[0].domain == "timelines" text_frames = [f for f in frames if isinstance(f, WsStreamText)] assert len(text_frames) == 1 assert text_frames[0].chunk == "Summary" diff --git a/tests/test_schemas_v3.py b/tests/test_schemas_v3.py index bcc1a7b..054c9d3 100644 --- a/tests/test_schemas_v3.py +++ b/tests/test_schemas_v3.py @@ -213,7 +213,7 @@ def test_stream_block_timeline(): frame = WsStreamBlock( request_id="r1", block_type="timeline", - data={"checkpoints": [{"id": "c1", "title": "Launch", "date": 1700000000}]}, + data={"timelines": [{"id": "c1", "title": "Launch", "date": 1700000000}]}, ) assert frame.block_type == "timeline" @@ -270,7 +270,7 @@ def test_floating_domain_tasks(): assert frame.domain == "tasks" -@pytest.mark.parametrize("domain", ["tasks", "checkpoints", "notes", "projects"]) +@pytest.mark.parametrize("domain", ["tasks", "timelines", "notes", "projects"]) def test_floating_domain_valid_domains(domain: str): frame = WsFloatingDomain(request_id="r1", domain=domain) # type: ignore[arg-type] assert frame.domain == domain