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) | | `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) | | `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) | | `notes` | id, projectId, title, content (markdown), createdAt (ms), updatedAt (ms) |
| `taskComments` | id, taskId, author, content, createdAt (ms) | | `taskComments` | id, taskId, author, content, createdAt (ms) |
| `clients` | id, parentId, name, industry, 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 - `update_project(project_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation
- `delete_project(project_id)`: `execute_on_client(action="delete", ...)` → return confirmation - `delete_project(project_id)`: `execute_on_client(action="delete", ...)` → return confirmation
- [x] **`app/agents/checkpoint_agent.py` (4 tools):** - [x] **`app/agents/timeline_agent.py` (4 tools):**
- `list_checkpoints(project_id)`: `execute_on_client(action="select", table="checkpoints", filters={projectId})` → format + return - `list_timelines(project_id)`: `execute_on_client(action="select", table="timelines", filters={projectId})` → format + return
- `create_checkpoint(project_id, title, date, ...)`: `execute_on_client(action="insert", table="checkpoints", data={...})` → return confirmation + id - `create_timeline(project_id, title, date, ...)`: `execute_on_client(action="insert", table="timelines", data={...})` → return confirmation + id
- `update_checkpoint(checkpoint_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation - `update_timeline(timeline_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation
- `delete_checkpoint(checkpoint_id)`: `execute_on_client(action="delete", ...)` → return confirmation - `delete_timeline(timeline_id)`: `execute_on_client(action="delete", ...)` → return confirmation
- [x] **`app/agents/note_agent.py` (5 tools):** - [x] **`app/agents/note_agent.py` (5 tools):**
- `list_notes(project_id)`: `execute_on_client(action="select", table="notes", filters={projectId})` → format + return - `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 - `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 - `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. - **Outcome:** All 23 tools query real user data via WS. LLM sees actual rows, not action descriptors.
### Step B.3 — Bidirectional WebSocket handler ### Step B.3 — Bidirectional WebSocket handler
@@ -282,7 +282,7 @@ Cloud Agent:
- `device_id` str — identifies which Electron install this config belongs to - `device_id` str — identifies which Electron install this config belongs to
- `name` str - `name` str
- `directory_paths` JSON — list of absolute paths on the device - `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 - `prompt_template` text — user-configured via Chatbot Journey
- `file_extensions` JSON — e.g. `[".eml", ".txt", ".pdf", ".md"]` - `file_extensions` JSON — e.g. `[".eml", ".txt", ".pdf", ".md"]`
- `schedule_cron` str — e.g. `"0 */6 * * *"` (every 6h) - `schedule_cron` str — e.g. `"0 */6 * * *"` (every 6h)
@@ -429,7 +429,7 @@ Cloud Agent:
- `POST /api/v1/agents/journey/message`: - `POST /api/v1/agents/journey/message`:
- Body: `{ session_id, message }` - Body: `{ session_id, message }`
- AI processes user's answer, asks follow-up questions (max 5 turns) - 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: "..." }` - 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.") - 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. - **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)` - 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 - 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 - Accepts flexible context; sentinel `-1` for optional integer update fields
- [x] `app/agents/checkpoint_agent.py` — `@registry.register`: - [x] `app/agents/timeline_agent.py` — `@registry.register`:
- Description: "Manages project checkpoints (milestones): list, create, update, delete" - Description: "Manages project timelines (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)` - 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 - `project_id` is required for create; date is a ms timestamp; supports AI-suggestion + approval workflow
- [x] `app/agents/project_agent.py` — `@registry.register`: - [x] `app/agents/project_agent.py` — `@registry.register`:
- Description: "Manages projects: list, get, create, update, archive, delete" - 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 - 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] `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) - [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 ✅ ### Step 7 — Storage Layer ✅
- [x] `app/storage/blob_store.py`: - [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 ## Key Features
1. **LLM-powered orchestration** — GPT-4o-mini classifies user intent and routes to the appropriate domain agent. 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. 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. 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. 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` | | **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` | | **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` | | **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. 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) ### 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) ### Built-in Playbooks (2)
@@ -643,7 +643,7 @@ Source: `app/marketplace/`
- Plugin ID must match `^[a-z0-9-]+$` - Plugin ID must match `^[a-z0-9-]+$`
- Permissions must be from the allowed set only - Permissions must be from the allowed set only
- No binary blobs in the manifest - 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. - `get_pending(db)` — Lists plugins awaiting review.
- `submit_review(db, plugin_id, reviewer_id, decision, notes)` — Records the review decision. - `submit_review(db, plugin_id, reviewer_id, decision, notes)` — Records the review decision.
@@ -734,7 +734,7 @@ adiuva-api/
│ ├── agents/ # LLM-powered domain agents │ ├── agents/ # LLM-powered domain agents
│ │ ├── task_agent.py # Task & comment CRUD (8 tools) │ │ ├── task_agent.py # Task & comment CRUD (8 tools)
│ │ ├── project_agent.py # Project lifecycle (6 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) │ │ └── note_agent.py # Markdown notes (5 tools)
│ │ │ │
│ ├── core/ # Orchestration engine │ ├── 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, ...) - `task` — TaskRow component (`TaskItem`: id, title, status, priority, assignee, dueDate, projectId, ...)
- `project` — Project card (id, name, clientId, status) - `project` — Project card (id, name, clientId, status)
- `note` — Note card (id, title, createdAt, projectId) - `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: **Table block** — buffered, validated:
```json ```json
@@ -178,7 +178,7 @@ Supported entity types (matching Electron component types):
**Timeline block** — buffered, validated (renders via GanttChart component): **Timeline block** — buffered, validated (renders via GanttChart component):
```json ```json
{ "type": "timeline", "checkpoints": [{ "id": "...", "title": "...", "date": 1234567890 }] } { "type": "timeline", "timelines": [{ "id": "...", "title": "...", "date": 1234567890 }] }
``` ```
### Changes ### Changes
@@ -192,13 +192,13 @@ Supported entity types (matching Electron component types):
- `chart` -> buffers until JSON complete, validates `chartType` against allowed set, yields `WsStreamBlock` - `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` - `entity_ref` -> looks up data from `agent.tool_results`, serializes full entity, yields `WsStreamBlock`
- `table` -> buffers, validates headers/rows structure, 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) - Invalid blocks are logged and skipped (never crash the stream)
- `FloatingFormatter`: - `FloatingFormatter`:
- Receives `agent_name` from orchestrator - Receives `agent_name` from orchestrator
- Maps agent name to domain (deterministic, by code — no LLM): - Maps agent name to domain (deterministic, by code — no LLM):
- `task_agent` -> `"tasks"` - `task_agent` -> `"tasks"`
- `checkpoint_agent` -> `"checkpoints"` - `timeline_agent` -> `"timelines"`
- `note_agent` -> `"notes"` - `note_agent` -> `"notes"`
- `project_agent` -> `"projects"` - `project_agent` -> `"projects"`
- Yields `WsFloatingDomain` immediately - Yields `WsFloatingDomain` immediately

View File

@@ -37,12 +37,12 @@ _SEED_PLUGINS = [
{ {
"id": "plugin-slack-notify", "id": "plugin-slack-notify",
"name": "Slack Notifier", "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", "version": "1.2.0",
"author_name": "Adiuva", "author_name": "Adiuva",
"category": "communication", "category": "communication",
"price_cents": 499, "price_cents": 499,
"permissions": json.dumps(["read:tasks", "read:checkpoints"]), "permissions": json.dumps(["read:tasks", "read:timelines"]),
"status": "approved", "status": "approved",
"s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip", "s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip",
"install_count": 0, "install_count": 0,

View File

@@ -1,5 +1,5 @@
"""Import all agent modules to trigger @registry.register decorators.""" """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 from __future__ import annotations
@@ -13,43 +13,43 @@ from app.core.llm import get_llm
from app.core.ws_context import execute_on_client from app.core.ws_context import execute_on_client
_SYSTEM_PROMPT = ( _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" "track progress on a project — they are not calendar events.\n\n"
"Rules:\n" "Rules:\n"
" - project_id is REQUIRED for every create; confirm with the user if unknown\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" " - 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" " - 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" " - For update_timeline, use -1 for integer fields you do not want to change\n"
" - Listing without a project_id returns all checkpoints across projects\n" " - Listing without a project_id returns all timelines across projects\n"
" - Always echo the title and formatted date in your confirmation." " - Always echo the title and formatted date in your confirmation."
) )
@tool @tool
async def list_checkpoints(project_id: str = "") -> str: async def list_timelines(project_id: str = "") -> str:
"""List checkpoints. Provide project_id to scope to a specific project.""" """List timelines. Provide project_id to scope to a specific project."""
result = await execute_on_client( result = await execute_on_client(
action="select", action="select",
table="checkpoints", table="timelines",
filters={"projectId": project_id or None}, filters={"projectId": project_id or None},
) )
rows = result.get("rows", []) rows = result.get("rows", [])
if not 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] 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 @tool
async def create_checkpoint( async def create_timeline(
project_id: str, project_id: str,
title: str, title: str,
date: int, date: int,
is_ai_suggested: int = 0, is_ai_suggested: int = 0,
is_approved: int = 0, is_approved: int = 0,
) -> str: ) -> str:
"""Create a project checkpoint (milestone). """Create a project timeline (milestone).
project_id: REQUIRED UUID of the parent project project_id: REQUIRED UUID of the parent project
title: descriptive name for the milestone title: descriptive name for the milestone
date: Unix timestamp in milliseconds date: Unix timestamp in milliseconds
@@ -58,7 +58,7 @@ async def create_checkpoint(
""" """
result = await execute_on_client( result = await execute_on_client(
action="insert", action="insert",
table="checkpoints", table="timelines",
data={ data={
"projectId": project_id, "projectId": project_id,
"title": title, "title": title,
@@ -68,18 +68,18 @@ async def create_checkpoint(
}, },
) )
row = result["row"] 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 @tool
async def update_checkpoint( async def update_timeline(
checkpoint_id: str, timeline_id: str,
title: str = "", title: str = "",
date: int = -1, date: int = -1,
is_approved: int = -1, is_approved: int = -1,
) -> str: ) -> str:
"""Update a checkpoint. Only pass fields that should change. """Update a timeline. Only pass fields that should change.
checkpoint_id: UUID of the checkpoint (required) timeline_id: UUID of the timeline (required)
date: -1 means unchanged; any other value sets the new date (ms timestamp) 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 is_approved: -1 means unchanged; 0 or 1 sets the approval state
""" """
@@ -92,30 +92,30 @@ async def update_checkpoint(
updates["isApproved"] = is_approved updates["isApproved"] = is_approved
result = await execute_on_client( result = await execute_on_client(
action="update", action="update",
table="checkpoints", table="timelines",
data={"id": checkpoint_id, "updates": updates}, data={"id": timeline_id, "updates": updates},
) )
row = result["row"] row = result["row"]
return f"Checkpoint updated: '{row['title']}' (id: {row['id']})" return f"Timeline updated: '{row['title']}' (id: {row['id']})"
@tool @tool
async def delete_checkpoint(checkpoint_id: str) -> str: async def delete_timeline(timeline_id: str) -> str:
"""Delete a checkpoint permanently by its UUID.""" """Delete a timeline permanently by its UUID."""
await execute_on_client(action="delete", table="checkpoints", data={"id": checkpoint_id}) await execute_on_client(action="delete", table="timelines", data={"id": timeline_id})
return f"Checkpoint {checkpoint_id} deleted." return f"Timeline {timeline_id} deleted."
@registry.register @registry.register
class CheckpointAgent(ChatAgent): class TimelineAgent(ChatAgent):
def get_name(self) -> str: def get_name(self) -> str:
return "checkpoint_agent" return "timeline_agent"
def get_description(self) -> str: 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]: 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: async def handle(self, query: str, context: dict[str, Any]) -> str:
llm = get_llm() 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): 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. 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). 3. How fields should be mapped (e.g. email subject → task title).
4. Priority or status rules (e.g. "urgent" keyword → high priority). 4. Priority or status rules (e.g. "urgent" keyword → high priority).
5. Any special handling, date extraction, or exclusions. 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 \ 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: 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: Rules for the generated template:
- Be explicit about field names (camelCase: title, status, priority, dueDate, projectId, content, etc.). - 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 & extraction schema hints ───────────────────────────────
_ALLOWED_TABLES: frozenset[str] = frozenset( _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. # 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)" "assignee (JSON array string), dueDate (ms timestamp int), projectId (str)"
), ),
"notes": "title (str, required), content (str, markdown), projectId (str)", "notes": "title (str, required), content (str, markdown), projectId (str)",
"checkpoints": ( "timelines": (
"title (str, required), projectId (str, required), date (ms timestamp int)" "title (str, required), projectId (str, required), date (ms timestamp int)"
), ),
"projects": "name (str, required), clientId (str)", "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, " "list, and track tasks. Use correct status values (todo, in_progress, "
"done) and priority values (high, medium, low) from the workspace model." "done) and priority values (high, medium, low) from the workspace model."
), ),
"tpl_checkpoint_agent_default": ( "tpl_timeline_agent_default": (
"You are a project checkpoint assistant. Help the user create and manage " "You are a project timeline assistant. Help the user create and manage "
"milestone checkpoints on their projects. Every checkpoint requires a " "milestone timelines on their projects. Every timeline requires a "
"project_id and a date expressed as a Unix timestamp in milliseconds." "project_id and a date expressed as a Unix timestamp in milliseconds."
), ),
"tpl_project_agent_default": ( "tpl_project_agent_default": (
@@ -182,7 +182,7 @@ def _register_builtin_templates() -> None:
"tpl_note_weekly_summary": ( "tpl_note_weekly_summary": (
"Generate a weekly project summary note from the provided workspace data. " "Generate a weekly project summary note from the provided workspace data. "
"Include: tasks completed this week, tasks due soon, active projects, " "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(): 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 # Map agent name → floating domain
_AGENT_DOMAIN: dict[str, str] = { _AGENT_DOMAIN: dict[str, str] = {
"task_agent": "tasks", "task_agent": "tasks",
"checkpoint_agent": "checkpoints", "timeline_agent": "timelines",
"note_agent": "notes", "note_agent": "notes",
"project_agent": "projects", "project_agent": "projects",
} }
@@ -171,8 +171,8 @@ class HomeFormatter:
) )
if block_type == "timeline": if block_type == "timeline":
if not isinstance(obj.get("checkpoints"), list): if not isinstance(obj.get("timelines"), list):
logger.warning("HomeFormatter: timeline missing checkpoints — skipping") logger.warning("HomeFormatter: timeline missing timelines — skipping")
return None return None
return WsStreamBlock( return WsStreamBlock(
request_id=self.request_id, request_id=self.request_id,

View File

@@ -29,8 +29,8 @@ ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
"write:projects", "write:projects",
"read:notes", "read:notes",
"write:notes", "write:notes",
"read:checkpoints", "read:timelines",
"write:checkpoints", "write:timelines",
"read:calendar", "read:calendar",
"write:calendar", "write:calendar",
} }

View File

@@ -268,7 +268,7 @@ class WsAgentComplete(BaseModel):
class WsFloatingScope(BaseModel): class WsFloatingScope(BaseModel):
"""Scope for a floating request — narrows the agent to a specific entity.""" """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 id: str | None = None
@@ -325,7 +325,7 @@ class WsFloatingDomain(BaseModel):
type: Literal[WsFrameType.floating_domain] = WsFrameType.floating_domain type: Literal[WsFrameType.floating_domain] = WsFrameType.floating_domain
request_id: str request_id: str
domain: Literal["tasks", "checkpoints", "notes", "projects"] domain: Literal["tasks", "timelines", "notes", "projects"]
# ── Agent Catalog ───────────────────────────────────────────────────── # ── Agent Catalog ─────────────────────────────────────────────────────

View File

@@ -129,12 +129,12 @@ _SEED_PLUGINS = [
Plugin( Plugin(
id="plugin-slack-notify", id="plugin-slack-notify",
name="Slack Notifier", 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", version="1.2.0",
author_name="Adiuva", author_name="Adiuva",
category="communication", category="communication",
price_cents=499, price_cents=499,
permissions=json.dumps(["read:tasks", "read:checkpoints"]), permissions=json.dumps(["read:tasks", "read:timelines"]),
status="approved", status="approved",
s3_package_key="plugins/plugin-slack-notify/1.2.0/package.zip", s3_package_key="plugins/plugin-slack-notify/1.2.0/package.zip",
install_count=0, install_count=0,

View File

@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import app.agents # noqa: F401 — triggers @registry.register decorators 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.note_agent import NoteAgent
from app.agents.project_agent import ProjectAgent from app.agents.project_agent import ProjectAgent
from app.agents.task_agent import TaskAgent from app.agents.task_agent import TaskAgent
@@ -110,12 +110,12 @@ class TestAgentRegistration:
def test_all_agents_registered(self) -> None: def test_all_agents_registered(self) -> None:
names = {a["name"] for a in registry.list_agents()} names = {a["name"] for a in registry.list_agents()}
assert { assert {
"task_agent", "checkpoint_agent", "project_agent", "note_agent" "task_agent", "timeline_agent", "project_agent", "note_agent"
}.issubset(names) }.issubset(names)
def test_registry_returns_correct_types(self) -> None: def test_registry_returns_correct_types(self) -> None:
assert isinstance(registry.get("task_agent"), TaskAgent) 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("project_agent"), ProjectAgent)
assert isinstance(registry.get("note_agent"), NoteAgent) assert isinstance(registry.get("note_agent"), NoteAgent)
@@ -336,94 +336,94 @@ class TestTaskAgentTools:
assert "c1" in result assert "c1" in result
# ── CheckpointAgent ─────────────────────────────────────────────────── # ── TimelineAgent ───────────────────────────────────────────────────
class TestCheckpointAgent: class TestTimelineAgent:
def test_name(self) -> None: def test_name(self) -> None:
assert CheckpointAgent().get_name() == "checkpoint_agent" assert TimelineAgent().get_name() == "timeline_agent"
def test_description(self) -> None: 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: 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: def test_tool_names(self) -> None:
names = {t.name for t in CheckpointAgent().get_tools()} names = {t.name for t in TimelineAgent().get_tools()}
assert names == {"list_checkpoints", "create_checkpoint", "update_checkpoint", "delete_checkpoint"} assert names == {"list_timelines", "create_timeline", "update_timeline", "delete_timeline"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_no_tool_calls(self) -> None: async def test_handle_no_tool_calls(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("No checkpoints found.") mock_cls.return_value = _mock_llm("No timelines found.")
result = await CheckpointAgent().handle("list checkpoints", {}) result = await TimelineAgent().handle("list timelines", {})
assert result == "No checkpoints found." assert result == "No timelines found."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_with_create_tool_call(self) -> None: 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( mock_cls.return_value = _mock_llm_with_tool_call(
"create_checkpoint", "create_timeline",
{"project_id": "p1", "title": "MVP Launch", "date": 1700000000000}, {"project_id": "p1", "title": "MVP Launch", "date": 1700000000000},
"Checkpoint 'MVP Launch' created.", "Timeline 'MVP Launch' created.",
) )
result = await CheckpointAgent().handle("add MVP checkpoint", {}) result = await TimelineAgent().handle("add MVP timeline", {})
assert result == "Checkpoint 'MVP Launch' created." assert result == "Timeline 'MVP Launch' created."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_accepts_empty_context(self) -> None: 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.") mock_cls.return_value = _mock_llm("Done.")
result = await CheckpointAgent().handle("show milestones", {}) result = await TimelineAgent().handle("show milestones", {})
assert isinstance(result, str) assert isinstance(result, str)
class TestCheckpointAgentTools: class TestTimelineAgentTools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_checkpoints_no_project(self) -> None: async def test_list_timelines_no_project(self) -> None:
from app.agents.checkpoint_agent import list_checkpoints from app.agents.timeline_agent import list_timelines
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 = {"rows": []} m.return_value = {"rows": []}
result = await list_checkpoints.ainvoke({}) result = await list_timelines.ainvoke({})
call_kwargs = m.call_args.kwargs call_kwargs = m.call_args.kwargs
assert call_kwargs["action"] == "select" assert call_kwargs["action"] == "select"
assert call_kwargs["table"] == "checkpoints" assert call_kwargs["table"] == "timelines"
assert call_kwargs["filters"]["projectId"] is None assert call_kwargs["filters"]["projectId"] is None
assert result == "No checkpoints found." assert result == "No timelines found."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_checkpoints_with_project(self) -> None: async def test_list_timelines_with_project(self) -> None:
from app.agents.checkpoint_agent import list_checkpoints from app.agents.timeline_agent import list_timelines
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 = {"rows": []} 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" assert m.call_args.kwargs["filters"]["projectId"] == "p1"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_checkpoint(self) -> None: async def test_create_timeline(self) -> None:
from app.agents.checkpoint_agent import create_checkpoint from app.agents.timeline_agent import create_timeline
fake_row = {"id": "cp1", "title": "Beta release", "date": 1700000000000} 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} m.return_value = {"row": fake_row}
result = await create_checkpoint.ainvoke({ result = await create_timeline.ainvoke({
"project_id": "p1", "title": "Beta release", "date": 1700000000000, "project_id": "p1", "title": "Beta release", "date": 1700000000000,
}) })
call_kwargs = m.call_args.kwargs call_kwargs = m.call_args.kwargs
assert call_kwargs["action"] == "insert" 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"]["projectId"] == "p1"
assert call_kwargs["data"]["title"] == "Beta release" assert call_kwargs["data"]["title"] == "Beta release"
assert call_kwargs["data"]["date"] == 1700000000000 assert call_kwargs["data"]["date"] == 1700000000000
assert "Beta release" in result assert "Beta release" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_checkpoint_ai_suggested(self) -> None: async def test_create_timeline_ai_suggested(self) -> None:
from app.agents.checkpoint_agent import create_checkpoint from app.agents.timeline_agent import create_timeline
fake_row = {"id": "cp1", "title": "Review", "date": 1700000000000} 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} 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, "project_id": "p1", "title": "Review", "date": 1700000000000, "is_ai_suggested": 1,
}) })
call_kwargs = m.call_args.kwargs call_kwargs = m.call_args.kwargs
@@ -431,12 +431,12 @@ class TestCheckpointAgentTools:
assert call_kwargs["data"]["isApproved"] == 0 assert call_kwargs["data"]["isApproved"] == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_checkpoint_approve(self) -> None: async def test_update_timeline_approve(self) -> None:
from app.agents.checkpoint_agent import update_checkpoint from app.agents.timeline_agent import update_timeline
fake_row = {"id": "c1", "title": "MVP", "isApproved": 1} 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} 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 call_kwargs = m.call_args.kwargs
assert call_kwargs["action"] == "update" assert call_kwargs["action"] == "update"
assert call_kwargs["data"]["id"] == "c1" assert call_kwargs["data"]["id"] == "c1"
@@ -444,23 +444,23 @@ class TestCheckpointAgentTools:
assert "c1" in result assert "c1" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_checkpoint_empty_updates(self) -> None: async def test_update_timeline_empty_updates(self) -> None:
from app.agents.checkpoint_agent import update_checkpoint from app.agents.timeline_agent import update_timeline
fake_row = {"id": "c1", "title": "MVP"} 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} 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"] == {} assert m.call_args.kwargs["data"]["updates"] == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_checkpoint(self) -> None: async def test_delete_timeline(self) -> None:
from app.agents.checkpoint_agent import delete_checkpoint from app.agents.timeline_agent import delete_timeline
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 = {"deleted": True} 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 call_kwargs = m.call_args.kwargs
assert call_kwargs["action"] == "delete" assert call_kwargs["action"] == "delete"
assert call_kwargs["table"] == "checkpoints" assert call_kwargs["table"] == "timelines"
assert call_kwargs["data"]["id"] == "c1" assert call_kwargs["data"]["id"] == "c1"
assert "c1" in result assert "c1" in result

View File

@@ -243,7 +243,7 @@ class TestPlanCache:
class TestModuleSingletons: class TestModuleSingletons:
def test_template_registry_has_all_agent_defaults(self) -> None: 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"), ( assert template_registry.has(f"tpl_{agent}_default"), (
f"Missing template: 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 @pytest.mark.asyncio
async def test_orchestrate_v3_get_called_with_agent_name(): async def test_orchestrate_v3_get_called_with_agent_name():
agent = _FixedAgent("checkpoint_agent") agent = _FixedAgent("timeline_agent")
reg = _make_registry("checkpoint_agent", 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) 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 ───────────────────────────────────────────── # ── orchestrate_v3_stream ─────────────────────────────────────────────

View File

@@ -115,7 +115,7 @@ async def test_home_formatter_table_block():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_home_formatter_timeline_block(): async def test_home_formatter_timeline_block():
req_id = "req-7" 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=[]) formatter = HomeFormatter(request_id=req_id, tool_results=[])
frames = await collect(formatter, _stream(("task_agent", timeline_json))) 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(): async def test_floating_formatter_text_only():
req_id = "pop-2" req_id = "pop-2"
formatter = FloatingFormatter(request_id=req_id) formatter = FloatingFormatter(request_id=req_id)
tokens = [("checkpoint_agent", ""), ("checkpoint_agent", "Summary")] tokens = [("timeline_agent", ""), ("timeline_agent", "Summary")]
frames = await collect(formatter, _stream(*tokens)) frames = await collect(formatter, _stream(*tokens))
assert isinstance(frames[0], WsFloatingDomain) 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)] text_frames = [f for f in frames if isinstance(f, WsStreamText)]
assert len(text_frames) == 1 assert len(text_frames) == 1
assert text_frames[0].chunk == "Summary" assert text_frames[0].chunk == "Summary"

View File

@@ -213,7 +213,7 @@ def test_stream_block_timeline():
frame = WsStreamBlock( frame = WsStreamBlock(
request_id="r1", request_id="r1",
block_type="timeline", block_type="timeline",
data={"checkpoints": [{"id": "c1", "title": "Launch", "date": 1700000000}]}, data={"timelines": [{"id": "c1", "title": "Launch", "date": 1700000000}]},
) )
assert frame.block_type == "timeline" assert frame.block_type == "timeline"
@@ -270,7 +270,7 @@ def test_floating_domain_tasks():
assert frame.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): def test_floating_domain_valid_domains(domain: str):
frame = WsFloatingDomain(request_id="r1", domain=domain) # type: ignore[arg-type] frame = WsFloatingDomain(request_id="r1", domain=domain) # type: ignore[arg-type]
assert frame.domain == domain assert frame.domain == domain