rename from checkpoint to timeline agent

This commit is contained in:
2026-03-10 23:17:38 +01:00
parent f6ed383b3a
commit 2de67213f8
19 changed files with 136 additions and 136 deletions

View File

@@ -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.

View File

@@ -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`:

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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": "<tasks|notes|checkpoints|projects>", "data": {{ <field: value> }} }}, ...]
[{{ "table": "<tasks|notes|timelines|projects>", "data": {{ <field: value> }} }}, ...]
Rules for the generated template:
- Be explicit about field names (camelCase: title, status, priority, dueDate, projectId, content, etc.).

View File

@@ -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)",

View File

@@ -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():

View File

@@ -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,

View File

@@ -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",
}

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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,

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -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"

View File

@@ -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