From 65825012ca759100ecf6f0ff4b53bb1c9e412982 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Tue, 3 Mar 2026 18:51:28 +0100 Subject: [PATCH 1/8] Add `eca__todo` tool for multi-step task planning and tracking Introduces a new native tool `eca__todo` to help the agent plan, organize, and track progress across complex, multi-step tasks. - Maintains a persistent TODO state per chat in the DB - Supports operations: plan, add, update, start, complete, delete, clear - Includes dependency tracking (blocked_by) and cycle detection - Enforces sequential execution by default to prevent improper batching - Covered by comprehensive unit and integration tests --- CHANGELOG.md | 2 + resources/prompts/code_agent.md | 19 + resources/prompts/tools/todo.md | 41 ++ src/eca/db.clj | 8 + src/eca/features/tools.clj | 8 +- src/eca/features/tools/todo.clj | 513 ++++++++++++++++++++++++++ test/eca/features/tools/todo_test.clj | 469 +++++++++++++++++++++++ test/eca/features/tools_test.clj | 8 + 8 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 resources/prompts/tools/todo.md create mode 100644 src/eca/features/tools/todo.clj create mode 100644 test/eca/features/tools/todo_test.clj diff --git a/CHANGELOG.md b/CHANGELOG.md index 228ed2fe9..61a02a5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add Todo tool #246 + ## 0.109.5 - Fix clear messages to reset usage tokens as well. diff --git a/resources/prompts/code_agent.md b/resources/prompts/code_agent.md index 598fab915..8ea5cbf1c 100644 --- a/resources/prompts/code_agent.md +++ b/resources/prompts/code_agent.md @@ -27,3 +27,22 @@ You have tools at your disposal to solve the coding task. Follow these rules reg 2. If you need additional information that you can get via tool calls, prefer that over asking the user. 3. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. 4. You have the capability to call multiple tools in a single response, batch your tool calls together for optimal performance. + +{% if toolEnabled_eca__todo %} +## TODO Tool + +You have access to a `eca__todo` tool for tracking multi-step work within this chat. + +### Workflow: +1. Use `plan` to create TODO with goal and tasks +2. Use `start` before working on a task (marks it as in_progress) +3. Use `complete` only for tasks that are actually finished; for each targeted task that has `done_when`, verify it first. +4. Use `add` if you discover additional work +5. When a plan is fully completed and no further work is needed for the current goal, always use the `clear` operation to clean up the workspace. +6. Delegate focused work to subagents when helpful. Work sequentially by default; batch operations ONLY for tasks executing simultaneously. + +### done_when guidance: +- `done_when` is optional. +- Prefer setting it for non-trivial tasks where completion should be objectively verifiable. +- For trivial tasks, you may omit it to keep tracking lightweight. +{% endif %} diff --git a/resources/prompts/tools/todo.md b/resources/prompts/tools/todo.md new file mode 100644 index 000000000..030c1cfd5 --- /dev/null +++ b/resources/prompts/tools/todo.md @@ -0,0 +1,41 @@ +Task management for planning and tracking work. + +When to Use: +- Tasks requiring 3 or more distinct steps +- User provides multiple tasks (numbered lists, comma-separated items) +- Non-trivial work that benefits from planning and tracking +- User explicitly requests task tracking or a todo list + +When NOT to Use: +- Single, trivial task completable in very few steps +- Purely informational or conversational queries +- Quick fixes where tracking adds no organizational value + +Operations: +- read: View current TODO state +- plan: Create/replace TODO with goal and tasks (required: goal, tasks) +- add: Append task(s) to existing TODO +- update: Modify a single task metadata by `id` (content, priority, done_when, blocked_by) — cannot change status +- start: Begin work on tasks by `ids` (sets to in_progress; rejects blocked or done tasks) +- complete: Mark tasks by `ids` as done (verify done_when criteria first) +- delete: Remove tasks by `ids` +- clear: Reset entire TODO (removes goal and all tasks) + +Workflow: +1. Use 'plan' to create TODO with goal and initial tasks +2. Use 'start' before working on a task — marks it as in_progress +3. Work sequentially by default. Batch 'start' or 'complete' operations (using multiple 'ids') ONLY for independent tasks being executed simultaneously (e.g., via subagents). +4. Use 'complete' only for tasks that are actually finished; for each targeted task that has `done_when`, verify it first — the response tells you which tasks got unblocked +5. Use 'add' if you discover additional work during execution +6. When a plan is fully completed and no further work is needed for the current goal, always use the 'clear' operation to clean up the workspace. + +How to use done_when: +- `done_when` is optional. +- Use it for non-trivial tasks where completion must be verifiable (tests passing, behavior implemented, specific file changes, etc.). +- For trivial/obvious tasks, you may omit `done_when` to avoid overhead. + +Task Completion Integrity: +- Mark tasks complete as soon as they are finished. +- If a task has `done_when`, ONLY mark it as completed when ALL `done_when` criteria are actually met (for each task in a batch). +- Do NOT complete if: tests failing, implementation partial, or errors unresolved. +- When completing reveals follow-up work, use 'add' to append new tasks. diff --git a/src/eca/db.clj b/src/eca/db.clj index 0bb71fdb6..e0bbba58f 100644 --- a/src/eca/db.clj +++ b/src/eca/db.clj @@ -60,6 +60,14 @@ :messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason") :content (or :string [::any-map]) ;; string for simple text, map/vector for structured content :content-id :string}] + :todo {:goal :string + :next-id :number + :tasks [{:id :number + :content :string + :status (or :pending :in-progress :done) + :priority (or :high :medium :low) + :done-when (or :string nil) + :blocked-by #{:number}}]} :tool-calls {"" {:status (or :initial :preparing :check-approval :waiting-approval :execution-approved :executing :rejected :cleanup diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 73aa16b0b..c8ba7e746 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -13,6 +13,7 @@ [eca.features.tools.mcp.clojure-mcp] [eca.features.tools.shell :as f.tools.shell] [eca.features.tools.skill :as f.tools.skill] + [eca.features.tools.todo :as f.tools.todo] [eca.features.tools.util :as tools.util] [eca.logger :as logger] [eca.messenger :as messenger] @@ -148,6 +149,7 @@ f.tools.editor/definitions f.tools.chat/definitions f.tools.skill/definitions + f.tools.todo/definitions (f.tools.agent/definitions config) (f.tools.custom/definitions config)))) @@ -156,9 +158,11 @@ (defn ^:private filter-subagent-tools "Filter tools for subagent execution. - Excludes spawn_agent to prevent nesting." + + - Excludes spawn_agent to prevent nesting. + - Excludes todo because TODO state is currently chat-local; it should be managed by the parent agent." [tools] - (filterv #(not= "spawn_agent" (:name %)) tools)) + (filterv #(not (contains? #{"spawn_agent" "todo"} (:name %))) tools)) (defn all-tools "Returns all available tools, including both native ECA tools diff --git a/src/eca/features/tools/todo.clj b/src/eca/features/tools/todo.clj new file mode 100644 index 000000000..3d97cdaab --- /dev/null +++ b/src/eca/features/tools/todo.clj @@ -0,0 +1,513 @@ +(ns eca.features.tools.todo + (:require + [clojure.string :as str] + [eca.features.tools.util :as tools.util])) + +(set! *warn-on-reflection* true) + +;; --- Helpers --- + +(def ^:private empty-todo {:goal "" :next-id 1 :tasks []}) +(def ^:private valid-priorities #{:high :medium :low}) + +(defn- error [msg] + (tools.util/single-text-content msg true)) + +(defn- find-task [state id] + (some #(when (= id (:id %)) %) (:tasks state))) + +(defn- task-index + "Index tasks by ID for O(1) lookups." + [tasks] + (into {} (map (juxt :id identity)) tasks)) + +(defn- active-blockers + "Sorted seq of IDs blocking task `id` (blockers not yet :done), or nil." + [tasks-by-id id] + (when-let [task (get tasks-by-id id)] + (->> (:blocked-by task) + (remove #(= :done (:status (get tasks-by-id %)))) + sort + seq))) + +;; --- State management --- + +(defn get-todo + "Get TODO state for the chat." + [db chat-id] + (get-in db [:chats chat-id :todo] empty-todo)) + +(defn- mutate-todo! + "Atomically update TODO for a chat via compare-and-set loop. + `mutate-fn` receives current state, returns {:state ...} or {:error true ...}. + Extra keys in the result map are preserved." + [db* chat-id mutate-fn] + (loop [] + (let [db @db* + state (get-todo db chat-id) + result (mutate-fn state)] + (if (:error result) + result + (let [new-db (assoc-in db [:chats chat-id :todo] (:state result))] + (if (compare-and-set! db* db new-db) + result + (recur))))))) + +;; --- Validation --- +;; Contract: validators return nil on success or an error response map. +;; Values flow through function arguments, not through validator return values. + +(defn- require-nonblank [label v] + (cond + (nil? v) + (error (str label " must be a non-blank string")) + + (not (string? v)) + (error (str label " must be a string")) + + (str/blank? v) + (error (str label " must be a non-blank string")))) + +(defn- validate-priority [priority] + (when-not (and (string? priority) + (contains? valid-priorities (keyword priority))) + (error (format "Invalid priority: %s. Allowed: high, medium, low" priority)))) + +(defn- validate-done-when [done-when] + (when-not (or (nil? done-when) (string? done-when)) + (error "done_when must be a string when provided"))) + +(defn- resolve-ids + "Validate task IDs. Returns [ids error]." + [state raw-ids] + (if-not (and (sequential? raw-ids) (seq raw-ids)) + [nil (error "'ids' must be a non-empty array")] + (let [ids (vec raw-ids)] + (cond + (not-every? integer? ids) + [nil (error "All IDs must be integers")] + + (not= (count ids) (count (distinct ids))) + [nil (error "Duplicate IDs are not allowed")] + + :else + (let [existing-ids (set (map :id (:tasks state))) + missing (remove existing-ids ids)] + (if (seq missing) + [nil (error (format "Tasks not found: %s" (str/join ", " (sort missing))))] + [ids nil])))))) + +(defn- resolve-id + "Validate a single task ID. Returns [id error]." + [state raw-id] + (cond + (not (integer? raw-id)) [nil (error "Task ID must be an integer")] + (not (find-task state raw-id)) [nil (error (format "Task %d not found" raw-id))] + :else [raw-id nil])) + +(defn- validate-blocked-by + "Validate blocked_by references. Returns [normalized-set error]." + [raw allowed-ids self-id] + (cond + (nil? raw) [#{} nil] + (not (sequential? raw)) [nil (error "blocked_by must be an array")] + :else + (let [ids (vec raw)] + (cond + (not-every? integer? ids) + [nil (error "blocked_by must contain integer task IDs")] + + (and self-id (some #{self-id} ids)) + [nil (error (format "Task cannot block itself (id: %d)" self-id))] + + :else + (let [bad (remove (set allowed-ids) ids)] + (if (seq bad) + [nil (error (format "Invalid blocked_by references: %s" (str/join ", " (sort bad))))] + [(set ids) nil])))))) + +(defn- detect-cycle + "DFS cycle detection. Returns error response if cycle found, nil otherwise." + [tasks] + (let [graph (into {} (map (juxt :id #(:blocked-by % #{}))) tasks)] + (letfn [(dfs [state node path] + (let [path-set (set path)] + (cond + (contains? path-set node) + (let [path-vec (vec path) + cycle (conj (subvec path-vec (.indexOf ^java.util.List path-vec node)) node)] + (error (format "Dependency cycle detected: %s" + (str/join " -> " (map #(str "Task " %) cycle))))) + + (contains? state node) state + + :else + (reduce (fn [s dep] + (let [res (dfs s dep (conj path node))] + (if (:error res) (reduced res) res))) + (conj state node) + (sort (get graph node #{}))))))] + (let [result (reduce (fn [state node] + (let [res (dfs state node [])] + (if (:error res) (reduced res) res))) + #{} + (sort (keys graph)))] + (when (:error result) result))))) + +;; --- Task construction --- + +(defn- make-task + "Build a task map from string-keyed JSON input. Returns {:task ...} or error response." + [raw-task id allowed-ids] + (let [{:strs [content priority blocked_by done_when]} raw-task] + (or (require-nonblank "content" content) + (when (contains? raw-task "status") + (error "Cannot set status when creating tasks. New tasks always start as pending; use 'start' or 'complete'.")) + (when (contains? raw-task "priority") + (validate-priority priority)) + (when (contains? raw-task "done_when") + (validate-done-when done_when)) + (let [[blocked-by err] (validate-blocked-by blocked_by allowed-ids id)] + (or err + {:task {:id id + :content content + :status :pending + :priority (if (contains? raw-task "priority") + (keyword priority) + :medium) + :done-when done_when + :blocked-by blocked-by}}))))) + +(defn- build-tasks + "Validate and build a batch of tasks. Returns {:tasks [...]} or error response." + [state raw-tasks] + (if-not (and (sequential? raw-tasks) (seq raw-tasks)) + (error "tasks must be a non-empty array") + (let [next-id (:next-id state 1) + existing-ids (set (map :id (:tasks state))) + batch-ids (set (range next-id (+ next-id (count raw-tasks)))) + all-ids (into existing-ids batch-ids)] + (reduce + (fn [acc [idx raw]] + (if-not (map? raw) + (reduced (error (format "Task at index %d must be an object" idx))) + (let [result (make-task raw (+ next-id idx) all-ids)] + (if (:error result) + (reduced result) + (update acc :tasks conj (:task result)))))) + {:tasks []} + (map-indexed vector raw-tasks))))) + +;; --- Response formatting --- + +(defn- status-counts [tasks] + (let [freqs (frequencies (map :status tasks))] + {:done (freqs :done 0) :in-progress (freqs :in-progress 0) :pending (freqs :pending 0)})) + +(defn- summary-line [tasks] + (let [{:keys [done in-progress pending]} (status-counts tasks)] + (format "%d done, %d in progress, %d pending" done in-progress pending))) + +(defn- summary-line-with-total [tasks] + (format "%s, %d total" (summary-line tasks) (count tasks))) + +(defn- read-task-line [{:keys [id content status priority done-when blocked-by]}] + (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) content)] + (str/join "\n" + (cond-> [base] + (and (string? done-when) (not-empty done-when)) (conj (str " done_when: " done-when)) + (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) + +(defn- read-text [state] + (let [tasks (:tasks state)] + (str "Goal: " (or (not-empty (:goal state)) "(none)") "\n" + "Summary: " (summary-line-with-total tasks) "\n" + "Tasks:\n" + (if (seq tasks) + (str/join "\n" (map read-task-line tasks)) + "(none)")))) + +(defn- todo-details + "Structured data for client rendering." + [state] + (let [{:keys [goal tasks]} state + tasks-by-id (task-index tasks) + {:keys [done in-progress pending]} (status-counts tasks)] + {:type :todoState + :goal (or goal "") + :inProgressTaskIds (mapv :id (filter #(= :in-progress (:status %)) tasks)) + :tasks (mapv (fn [{:keys [id content status priority done-when blocked-by]}] + (cond-> {:id id :content content + :status (name status) + :priority (name priority) + :isBlocked (boolean (active-blockers tasks-by-id id))} + (and (string? done-when) (not-empty done-when)) (assoc :doneWhen done-when) + (seq blocked-by) (assoc :blockedBy (vec (sort blocked-by))))) + tasks) + :summary {:done done :inProgress in-progress :pending pending :total (count tasks)}})) + +(defn- success [state text] + (assoc (tools.util/single-text-content text) :details (todo-details state))) + +(defn- format-tasks-list [tasks] + (str/join "\n" (map #(format "- #%d: %s" (:id %) (:content %)) tasks))) + +(defmethod tools.util/tool-call-details-after-invocation :todo + [_name _arguments before-details result _ctx] + (or (:details result) before-details)) + +;; --- Operations --- + +(defn- op-read [_arguments {:keys [db chat-id]}] + (let [state (get-todo db chat-id)] + (success state (read-text state)))) + +(defn- op-plan [{:strs [goal tasks]} {:keys [db* chat-id]}] + (or (require-nonblank "goal" goal) + (when-not (and (sequential? tasks) (seq tasks)) + (error "plan requires 'tasks' (non-empty array)")) + (let [result (mutate-todo! db* chat-id + (fn [_state] + (let [fresh (assoc empty-todo :goal goal) + built (build-tasks fresh tasks)] + (if (:error built) + built + (or (detect-cycle (:tasks built)) + {:state (assoc fresh + :tasks (:tasks built) + :next-id (inc (count (:tasks built))))})))))] + (if (:error result) + result + (success (:state result) + (str "TODO created with " (count (get-in result [:state :tasks])) " tasks")))))) + +(defn- op-add [{:strs [tasks task] :as _arguments} {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id + (fn [state] + (cond + tasks + (let [built (build-tasks state tasks)] + (if (:error built) + built + (or (detect-cycle (into (:tasks state) (:tasks built))) + {:state (-> state + (update :tasks into (:tasks built)) + (update :next-id + (count (:tasks built)))) + :added (:tasks built)}))) + + (map? task) + (let [result (make-task task + (:next-id state) + (set (map :id (:tasks state))))] + (if (:error result) + result + (or (detect-cycle (conj (:tasks state) (:task result))) + {:state (-> state + (update :tasks conj (:task result)) + (update :next-id inc)) + :added [(:task result)]}))) + + :else + (error "add requires 'task' or 'tasks'"))))] + (if (:error result) + result + (let [added (:added result) + ids (mapv :id added)] + (success (:state result) + (format "Added %d task(s): %s" (count added) (str/join ", " ids))))))) + +(def ^:private updatable-keys #{"content" "priority" "done_when" "blocked_by"}) + +(defn- op-update [{:strs [id task]} {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id + (fn [state] + (let [[id err] (resolve-id state id)] + (or err + (cond + (not (map? task)) + (error "Missing 'task' object") + + (contains? task "status") + (error "Cannot set status via update. Use 'start' or 'complete'.") + + (empty? (select-keys task updatable-keys)) + (error "No updatable fields in task") + + :else + (let [{:strs [content priority done_when blocked_by]} task] + (or (when (contains? task "content") + (require-nonblank "content" content)) + (when (contains? task "priority") + (validate-priority priority)) + (when (contains? task "done_when") + (validate-done-when done_when)) + (let [[blocked-by err] (if (contains? task "blocked_by") + (validate-blocked-by + blocked_by + (map :id (:tasks state)) + id) + [nil nil])] + (or err + (let [new-tasks (mapv (fn [t] + (if (= id (:id t)) + (cond-> t + (contains? task "content") (assoc :content content) + (contains? task "priority") (assoc :priority (keyword priority)) + (contains? task "done_when") (assoc :done-when done_when) + (contains? task "blocked_by") (assoc :blocked-by blocked-by)) + t)) + (:tasks state))] + (or (detect-cycle new-tasks) + {:state (assoc state :tasks new-tasks) :task-id id})))))))))))] + (if (:error result) + result + (let [state (:state result) + task (find-task state (:task-id result))] + (success state (format "Task %d updated:\n%s" + (:task-id result) + (format-tasks-list [task]))))))) + +(defn- set-status + "Set status on tasks by IDs." + [tasks id-set status] + (mapv #(if (contains? id-set (:id %)) (assoc % :status status) %) tasks)) + +(defn- op-start [{:strs [ids]} {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id + (fn [state] + (let [tasks-by-id (task-index (:tasks state)) + [ids err] (resolve-ids state ids)] + (or err + (some (fn [id] + (let [task (get tasks-by-id id)] + (cond + (= :done (:status task)) + (error (format "Cannot start task %d: already done" id)) + + (seq (active-blockers tasks-by-id id)) + (error (format "Cannot start task %d: blocked by %s" + id (str/join ", " (active-blockers tasks-by-id id))))))) + ids) + {:state (assoc state :tasks (set-status (:tasks state) (set ids) :in-progress)) + :ids ids}))))] + (if (:error result) + result + (let [state (:state result) + started (filter #(contains? (set (:ids result)) (:id %)) (:tasks state))] + (success state + (format "Started %d task(s):\n%s" + (count started) + (format-tasks-list started))))))) + +(defn- op-complete [{:strs [ids]} {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id + (fn [state] + (let [tasks-by-id (task-index (:tasks state)) + [ids err] (resolve-ids state ids)] + (or err + (some (fn [id] + (let [task (get tasks-by-id id) + blockers (active-blockers tasks-by-id id)] + (when (and (not= :done (:status task)) + (seq blockers)) + (error (format "Cannot complete task %d: blocked by %s" + id + (str/join ", " blockers)))))) + ids) + {:state (assoc state :tasks (set-status (:tasks state) (set ids) :done)) + :ids ids}))))] + (if (:error result) + result + (let [state (:state result) + tasks-by-id (task-index (:tasks state)) + ids (set (:ids result)) + unblocked (keep (fn [t] + (when (and (= :pending (:status t)) + (some ids (:blocked-by t)) + (not (active-blockers tasks-by-id (:id t)))) + (:id t))) + (:tasks state)) + completed (filter #(contains? ids (:id %)) (:tasks state))] + (success state + (str (format "Completed %d task(s):\n%s" + (count completed) + (format-tasks-list completed)) + (when (seq unblocked) + (format "\nUnblocked: %s" (str/join ", " unblocked))))))))) + +(defn- op-delete [{:strs [ids]} {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id + (fn [state] + (let [[ids err] (resolve-ids state ids)] + (or err + (let [id-set (set ids) + remaining (->> (:tasks state) + (remove #(contains? id-set (:id %))) + (mapv #(update % :blocked-by (fn [b] (reduce disj b ids)))))] + {:state (assoc state :tasks remaining) + :ids ids + :deleted (filter #(contains? id-set (:id %)) (:tasks state))})))))] + (if (:error result) + result + (success (:state result) + (format "Deleted %d task(s):\n%s" + (count (:deleted result)) + (format-tasks-list (:deleted result))))))) + +(defn- op-clear [_arguments {:keys [db* chat-id]}] + (let [result (mutate-todo! db* chat-id (fn [_] {:state empty-todo}))] + (if (:error result) + result + (success (:state result) "TODO cleared")))) + +;; --- Dispatch --- + +(def ^:private ops + {"read" op-read + "plan" op-plan + "add" op-add + "update" op-update + "start" op-start + "complete" op-complete + "delete" op-delete + "clear" op-clear}) + +(defn- execute-todo [arguments ctx] + (let [op (get arguments "op") + handler (get ops op)] + (if handler + (handler arguments ctx) + (error (str "Unknown operation: " op))))) + +(def definitions + {"todo" + {:description (tools.util/read-tool-description "todo") + :parameters {:type "object" + :properties {:op {:type "string" + :enum ["read" "plan" "add" "update" "start" "complete" "delete" "clear"] + :description "Operation to perform"} + :id {:type ["integer" "null"] + :description "Task ID (required for update)"} + :ids {:type "array" + :items {:type "integer"} + :description "Task IDs (required for start/complete/delete)"} + :goal {:type "string" + :description "Overall objective (required for plan)"} + :task {:type "object" + :description "Single task data (for add/update)" + :properties {:content {:type "string" :description "Task description"} + :priority {:type "string" :enum ["high" "medium" "low"] + :description "Task priority (default: medium)"} + :done_when {:type "string" :description "Optional acceptance criteria (recommended for non-trivial tasks)"} + :blocked_by {:type "array" :items {:type "integer"} + :description "IDs of blocking tasks"}}} + :tasks {:type "array" + :description "Array of tasks (required for plan, alternative for add)" + :items {:type "object" + :properties {:content {:type "string" :description "Task description (required)"} + :priority {:type "string" :enum ["high" "medium" "low"]} + :done_when {:type "string" :description "Optional acceptance criteria (recommended for non-trivial tasks)"} + :blocked_by {:type "array" :items {:type "integer"}}} + :required ["content"]}}} + :required ["op"]} + :handler execute-todo}}) diff --git a/test/eca/features/tools/todo_test.clj b/test/eca/features/tools/todo_test.clj new file mode 100644 index 000000000..01c54bd6e --- /dev/null +++ b/test/eca/features/tools/todo_test.clj @@ -0,0 +1,469 @@ +(ns eca.features.tools.todo-test + (:require + [clojure.test :refer [deftest is testing]] + [matcher-combinators.test :refer [match?]] + [eca.features.tools :as f.tools] + [eca.features.tools.todo :as todo])) + +(set! *warn-on-reflection* true) + +;; --- Chat Isolation Tests --- + +(deftest chat-isolation-test + (testing "each chat has its own TODO" + (let [db* (atom {}) + handler (get-in todo/definitions ["todo" :handler])] + (handler {"op" "add" "task" {"content" "Chat 1 Task"}} {:db* db* :chat-id "chat-1"}) + (handler {"op" "add" "task" {"content" "Chat 2 Task"}} {:db* db* :chat-id "chat-2"}) + (is (= ["Chat 1 Task"] (map :content (:tasks (todo/get-todo @db* "chat-1"))))) + (is (= ["Chat 2 Task"] (map :content (:tasks (todo/get-todo @db* "chat-2"))))))) + + (testing "empty chat returns empty TODO" + (is (= {:goal "" :next-id 1 :tasks []} + (todo/get-todo {} "nonexistent-chat"))))) + +;; --- State Access Tests --- + +(deftest get-todo-test + (testing "returns empty todo for missing chat state" + (is (= {:goal "" :next-id 1 :tasks []} + (todo/get-todo {} "missing-chat")))) + + (testing "returns stored todo state as-is" + (let [state {:goal "G" + :next-id 2 + :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]} + db {:chats {"c1" {:todo state}}}] + (is (= state (todo/get-todo db "c1")))))) + +;; --- Read Operation Tests --- + +(deftest op-read-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "reads empty state" + (let [result (handler {"op" "read"} {:db {} :chat-id "c1"})] + (is (not (:error result))) + (is (match? {:details {:type :todoState :goal "" :tasks []}} result)))) + + (testing "includes structured details" + (let [db {:chats {"c1" {:todo {:goal "Test Goal" + :tasks [{:id 1 + :content "Task 1" + :status :in-progress + :priority :high + :done-when "criteria" + :blocked-by #{}}]}}}} + result (handler {"op" "read"} {:db db :chat-id "c1"})] + (is (match? {:details {:type :todoState + :goal "Test Goal" + :inProgressTaskIds [1] + :tasks [{:id 1 + :content "Task 1" + :status "in-progress" + :priority "high" + :isBlocked false + :doneWhen "criteria"}] + :summary {:done 0 + :inProgress 1 + :pending 0 + :total 1}}} + result)))) + + (testing "read returns full task list text for llm" + (let [db {:chats {"c1" {:todo {:goal "Test Goal" + :tasks [{:id 1 + :content "Task 1" + :status :in-progress + :priority :high + :done-when "criteria" + :blocked-by #{}} + {:id 2 + :content "Task 2" + :status :pending + :priority :medium + :blocked-by #{1}}]}}}} + result (handler {"op" "read"} {:db db :chat-id "c1"}) + text (get-in result [:contents 0 :text])] + (is (string? text)) + (is (re-find #"Goal: Test Goal" text)) + (is (re-find #"Summary: 0 done, 1 in progress, 1 pending, 2 total" text)) + (is (re-find #"#1 \[in-progress\] \[high\] Task 1" text)) + (is (re-find #"done_when: criteria" text)) + (is (re-find #"#2 \[pending\] \[medium\] Task 2" text)) + (is (re-find #"blocked_by: 1" text)))))) + +;; --- Plan Operation Tests --- + +(deftest op-plan-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "creates TODO with goal and tasks" + (let [db* (atom {}) + result (handler {"op" "plan" + "goal" "My Goal" + "tasks" [{"content" "Task 1"} + {"content" "Task 2" "blocked_by" [1]}]} + {:db* db* :chat-id "c1"})] + (is (not (:error result))) + (is (= "My Goal" (get-in @db* [:chats "c1" :todo :goal]))) + (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= ["Task 1" "Task 2"] (map :content (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) + (is (= :medium (get-in @db* [:chats "c1" :todo :tasks 0 :priority]))))) + + (testing "replaces existing TODO completely" + (let [db* (atom {:chats {"c1" {:todo {:goal "Old Goal" + :tasks [{:id 1 :content "Old Task" :status :in-progress} + {:id 2 :content "Old Task 2" :status :pending}] + :next-id 3}}}})] + (handler {"op" "plan" + "goal" "New Goal" + "tasks" [{"content" "New Task"}]} + {:db* db* :chat-id "c1"}) + (is (= "New Goal" (get-in @db* [:chats "c1" :todo :goal]))) + (is (not-any? #(= "Old Task" (:content %)) + (get-in @db* [:chats "c1" :todo :tasks]))) + (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= "New Task" (get-in @db* [:chats "c1" :todo :tasks 0 :content]))) + (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) + + (testing "requires goal" + (let [db* (atom {}) + result (handler {"op" "plan" "tasks" [{"content" "Task 1"}]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"goal must be a non-blank string"}]} result)))) + + (testing "requires non-blank goal" + (let [db* (atom {}) + result (handler {"op" "plan" "goal" " " "tasks" [{"content" "Task 1"}]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"goal must be a non-blank string"}]} result)))) + + (testing "requires tasks" + (let [db* (atom {}) + result (handler {"op" "plan" "goal" "My Goal"} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"requires 'tasks'"}]} result)))) + + (testing "requires non-empty tasks array" + (let [db* (atom {}) + result (handler {"op" "plan" "goal" "My Goal" "tasks" []} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) + + (testing "rejects status in plan task payload" + (let [db* (atom {}) + result (handler {"op" "plan" + "goal" "My Goal" + "tasks" [{"content" "Task 1" "status" "done"}]} + {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Cannot set status when creating tasks"}]} result)))))) + +;; --- Add Operation Tests --- + +(deftest op-add-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "adds single task" + (let [db* (atom {})] + (handler {"op" "add" "task" {"content" "Task 1"}} {:db* db* :chat-id "c1"}) + (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= "Task 1" (get-in @db* [:chats "c1" :todo :tasks 0 :content]))) + (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) + + (testing "adds batch of tasks" + (let [db* (atom {})] + (handler {"op" "add" "tasks" [{"content" "T1"} {"content" "T2"}]} {:db* db* :chat-id "c1"}) + (let [tasks (get-in @db* [:chats "c1" :todo :tasks])] + (is (= 2 (count tasks))) + (is (= [1 2] (map :id tasks)))))) + + (testing "validates required content" + (let [db* (atom {}) + result (handler {"op" "add" "task" {"content" ""}} {:db* db* :chat-id "c1"})] + (is (:error result)))) + + (testing "rejects status in add payload" + (let [db* (atom {}) + result (handler {"op" "add" "task" {"content" "Task 1" "status" "done"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Cannot set status when creating tasks"}]} result)))) + + (testing "requires task or tasks" + (let [db* (atom {}) + result (handler {"op" "add"} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"add requires"}]} result)))))) + +;; --- Update Operation Tests --- + +(deftest op-update-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "updates task metadata" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "Old" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "update" "id" 1 "task" {"content" "New" "priority" "high"}} {:db* db* :chat-id "c1"}) + (let [task (first (get-in @db* [:chats "c1" :todo :tasks]))] + (is (= "New" (:content task))) + (is (= :high (:priority task)))))) + + (testing "rejects status changes via update" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "update" "id" 1 "task" {"status" "done"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Cannot set status via update"}]} result)) + (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))))) + + (testing "rejects empty updates" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending}]}}}}) + result (handler {"op" "update" "id" 1 "task" {}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"No updatable fields"}]} result)))) + + (testing "returns an error response for unknown id" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending}]}}}}) + result (handler {"op" "update" "id" 999 "task" {"content" "New"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"not found"}]} result)))))) + +;; --- Start Operation Tests --- + +(deftest op-start-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "starts pending tasks by ids" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "start" "ids" [1 2]} {:db* db* :chat-id "c1"}) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + + (testing "does not demote other in-progress tasks" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "start" "ids" [2]} {:db* db* :chat-id "c1"}) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + + (testing "requires ids for start" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start"} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) + + (testing "rejects starting a done task" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :done :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "ids" [1]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"already done"}]} result)))) + + (testing "rejects starting a blocked task" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + result (handler {"op" "start" "ids" [2]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"blocked by 1"}]} result)) + (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + + (testing "rejects duplicate ids for start" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "ids" [1 1]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) + +;; --- Complete Operation Tests --- + +(deftest op-complete-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "completes tasks by ids" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "complete" "ids" [1 2]} {:db* db* :chat-id "c1"}) + (is (= :done (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) + (is (= :done (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + + (testing "shows unblocked tasks" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + result (handler {"op" "complete" "ids" [1]} {:db* db* :chat-id "c1"})] + (is (match? {:contents [{:type :text :text #"Unblocked"}]} result)))) + + (testing "rejects blocked tasks for complete" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + result (handler {"op" "complete" "ids" [2]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Cannot complete task 2: blocked by 1"}]} result)) + (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + + (testing "requires ids for complete" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "complete"} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) + + (testing "rejects duplicate ids for complete" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "complete" "ids" [1 1]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) + +;; --- Delete Operation Tests --- + +(deftest op-delete-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "deletes tasks by ids and clears blocked_by references" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}} + {:id 3 :content "T3" :status :pending :priority :medium :blocked-by #{1 2}}]}}}})] + (handler {"op" "delete" "ids" [1 2]} {:db* db* :chat-id "c1"}) + (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= 3 (get-in @db* [:chats "c1" :todo :tasks 0 :id]))) + (is (empty? (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) + + (testing "requires ids for delete" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "delete"} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) + + (testing "rejects duplicate ids for delete" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "delete" "ids" [1 1]} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) + +;; --- Clear Operation Tests --- + +(deftest op-clear-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "resets to empty state" + (let [db* (atom {:chats {"c1" {:todo {:goal "G" :tasks [{:id 1}] :next-id 2}}}})] + (handler {"op" "clear"} {:db* db* :chat-id "c1"}) + (is (empty? (get-in @db* [:chats "c1" :todo :tasks]))) + (is (= 1 (get-in @db* [:chats "c1" :todo :next-id]))) + (is (= "" (get-in @db* [:chats "c1" :todo :goal]))))))) + +;; --- Priority Validation Tests --- + +(deftest priority-validation-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "rejects invalid priority in add" + (let [db* (atom {}) + result (handler {"op" "add" "task" {"content" "T1" "priority" "urgent"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)))) + + (testing "rejects invalid priority in update" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "update" "id" 1 "task" {"priority" "urgent"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) + (is (= :medium (get-in @db* [:chats "c1" :todo :tasks 0 :priority]))))) + + (testing "rejects uppercase priority values" + (let [db* (atom {}) + result (handler {"op" "add" "task" {"content" "T1" "priority" "HIGH"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) + (is (empty? (:tasks (todo/get-todo @db* "c1")))))))) + +;; --- Blocked By Validation Tests --- + +(deftest blocked-by-validation-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "rejects non-array blocked_by" + (let [db* (atom {:chats {"c1" {:todo {:tasks []}}}}) + result (handler {"op" "add" "task" {"content" "T1" "blocked_by" "not an array"}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"blocked_by must be an array"}]} result)))) + + (testing "rejects blocked_by with non-integer ids" + (let [db* (atom {}) + result (handler {"op" "add" "task" {"content" "T1" "blocked_by" ["nope"]}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"blocked_by must contain integer task IDs"}]} result)) + (is (empty? (:tasks (todo/get-todo @db* "c1")))))) + + (testing "rejects self-reference in update" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "update" "id" 1 "task" {"blocked_by" [1]}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"cannot block itself"}]} result)))))) + +;; --- Dependency Cycle Detection Tests --- + +(deftest dependency-cycle-detection-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "detects 2-node cycle via update" + (let [db* (atom {:chats {"c1" {:todo {:next-id 3 + :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Dependency cycle detected: Task 1 -> Task 2 -> Task 1"}]} + result)))) + + (testing "detects cycle involving existing + new tasks" + (let [db* (atom {:chats {"c1" {:todo {:next-id 2 + :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + _ (handler {"op" "add" "task" {"content" "T2" "blocked_by" [1]}} {:db* db* :chat-id "c1"}) + result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Dependency cycle detected"}]} result)))) + + (testing "detects cycle in disconnected component" + (let [db* (atom {}) + result (handler {"op" "plan" + "goal" "Test" + "tasks" [{"content" "Task 1"} + {"content" "Task 2" "blocked_by" [3]} + {"content" "Task 3" "blocked_by" [2]}]} + {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Dependency cycle detected"}]} result)))))) + +;; --- Forward Reference in Batch Tests --- + +(deftest forward-reference-test + (let [handler (get-in todo/definitions ["todo" :handler])] + (testing "allows forward references in batch add" + (let [db* (atom {})] + (handler {"op" "plan" + "goal" "Test" + "tasks" [{"content" "Task 1" "blocked_by" [2]} + {"content" "Task 2"}]} + {:db* db* :chat-id "c1"}) + (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= #{2} (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) + + (testing "allows reference to earlier task in batch" + (let [db* (atom {})] + (handler {"op" "plan" + "goal" "Test" + "tasks" [{"content" "Task 1"} + {"content" "Task 2" "blocked_by" [1]}]} + {:db* db* :chat-id "c1"}) + (is (= #{1} (get-in @db* [:chats "c1" :todo :tasks 1 :blocked-by]))))) + + (testing "rejects reference to non-existent task" + (let [db* (atom {}) + result (handler {"op" "plan" + "goal" "Test" + "tasks" [{"content" "Task 1" "blocked_by" [99]}]} + {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Invalid blocked_by references"}]} result)))))) + +(deftest todo-tool-call-details-test + (testing "todo details are propagated to tool call details for clients" + (let [result {:error false + :details {:type :todoState + :goal "Goal" + :tasks [] + :summary {:done 0 :inProgress 0 :pending 0 :total 0}} + :contents [{:type :text :text "TODO created"}]} + details (f.tools/tool-call-details-after-invocation + :todo + {"op" "plan"} + nil + result + nil)] + (is (= (:details result) details))))) diff --git a/test/eca/features/tools_test.clj b/test/eca/features/tools_test.clj index 7c2edfadd..553b47b2d 100644 --- a/test/eca/features/tools_test.clj +++ b/test/eca/features/tools_test.clj @@ -34,6 +34,14 @@ :parameters some? :origin :native}]) (f.tools/all-tools "123" "code" {} {})))) + + (testing "Subagent excludes spawn_agent and todo tools" + (let [db {:chats {"sub-1" {:subagent {:name "explorer"}}}} + tools (f.tools/all-tools "sub-1" "code" db {}) + tool-names (set (map :name tools))] + (is (not (contains? tool-names "spawn_agent"))) + (is (not (contains? tool-names "todo"))))) + (testing "Do not include disabled native tools" (is (match? (m/embeds [(m/mismatch {:name "directory_tree"})]) From b4b0cbd1229b0612fe8630262bf5d1e78a2f7f1e Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 4 Mar 2026 10:15:03 -0300 Subject: [PATCH 2/8] Small fixes --- src/eca/features/tools/todo.clj | 110 +++++++++++++------------- test/eca/features/tools/todo_test.clj | 6 +- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/eca/features/tools/todo.clj b/src/eca/features/tools/todo.clj index 3d97cdaab..cb14ab095 100644 --- a/src/eca/features/tools/todo.clj +++ b/src/eca/features/tools/todo.clj @@ -10,18 +10,18 @@ (def ^:private empty-todo {:goal "" :next-id 1 :tasks []}) (def ^:private valid-priorities #{:high :medium :low}) -(defn- error [msg] +(defn ^:private error [msg] (tools.util/single-text-content msg true)) -(defn- find-task [state id] +(defn ^:private find-task [state id] (some #(when (= id (:id %)) %) (:tasks state))) -(defn- task-index +(defn ^:private task-index "Index tasks by ID for O(1) lookups." [tasks] (into {} (map (juxt :id identity)) tasks)) -(defn- active-blockers +(defn ^:private active-blockers "Sorted seq of IDs blocking task `id` (blockers not yet :done), or nil." [tasks-by-id id] (when-let [task (get tasks-by-id id)] @@ -37,7 +37,7 @@ [db chat-id] (get-in db [:chats chat-id :todo] empty-todo)) -(defn- mutate-todo! +(defn ^:private mutate-todo! "Atomically update TODO for a chat via compare-and-set loop. `mutate-fn` receives current state, returns {:state ...} or {:error true ...}. Extra keys in the result map are preserved." @@ -57,7 +57,7 @@ ;; Contract: validators return nil on success or an error response map. ;; Values flow through function arguments, not through validator return values. -(defn- require-nonblank [label v] +(defn ^:private require-nonblank [label v] (cond (nil? v) (error (str label " must be a non-blank string")) @@ -68,16 +68,16 @@ (str/blank? v) (error (str label " must be a non-blank string")))) -(defn- validate-priority [priority] +(defn ^:private validate-priority [priority] (when-not (and (string? priority) (contains? valid-priorities (keyword priority))) (error (format "Invalid priority: %s. Allowed: high, medium, low" priority)))) -(defn- validate-done-when [done-when] +(defn ^:private validate-done-when [done-when] (when-not (or (nil? done-when) (string? done-when)) (error "done_when must be a string when provided"))) -(defn- resolve-ids +(defn ^:private resolve-ids "Validate task IDs. Returns [ids error]." [state raw-ids] (if-not (and (sequential? raw-ids) (seq raw-ids)) @@ -97,7 +97,7 @@ [nil (error (format "Tasks not found: %s" (str/join ", " (sort missing))))] [ids nil])))))) -(defn- resolve-id +(defn ^:private resolve-id "Validate a single task ID. Returns [id error]." [state raw-id] (cond @@ -105,7 +105,7 @@ (not (find-task state raw-id)) [nil (error (format "Task %d not found" raw-id))] :else [raw-id nil])) -(defn- validate-blocked-by +(defn ^:private validate-blocked-by "Validate blocked_by references. Returns [normalized-set error]." [raw allowed-ids self-id] (cond @@ -126,7 +126,7 @@ [nil (error (format "Invalid blocked_by references: %s" (str/join ", " (sort bad))))] [(set ids) nil])))))) -(defn- detect-cycle +(defn ^:private detect-cycle "DFS cycle detection. Returns error response if cycle found, nil otherwise." [tasks] (let [graph (into {} (map (juxt :id #(:blocked-by % #{}))) tasks)] @@ -156,7 +156,7 @@ ;; --- Task construction --- -(defn- make-task +(defn ^:private make-task "Build a task map from string-keyed JSON input. Returns {:task ...} or error response." [raw-task id allowed-ids] (let [{:strs [content priority blocked_by done_when]} raw-task] @@ -178,7 +178,7 @@ :done-when done_when :blocked-by blocked-by}}))))) -(defn- build-tasks +(defn ^:private build-tasks "Validate and build a batch of tasks. Returns {:tasks [...]} or error response." [state raw-tasks] (if-not (and (sequential? raw-tasks) (seq raw-tasks)) @@ -200,25 +200,25 @@ ;; --- Response formatting --- -(defn- status-counts [tasks] +(defn ^:private status-counts [tasks] (let [freqs (frequencies (map :status tasks))] {:done (freqs :done 0) :in-progress (freqs :in-progress 0) :pending (freqs :pending 0)})) -(defn- summary-line [tasks] +(defn ^:private summary-line [tasks] (let [{:keys [done in-progress pending]} (status-counts tasks)] (format "%d done, %d in progress, %d pending" done in-progress pending))) -(defn- summary-line-with-total [tasks] +(defn ^:private summary-line-with-total [tasks] (format "%s, %d total" (summary-line tasks) (count tasks))) -(defn- read-task-line [{:keys [id content status priority done-when blocked-by]}] +(defn ^:private read-task-line [{:keys [id content status priority done-when blocked-by]}] (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) content)] (str/join "\n" (cond-> [base] (and (string? done-when) (not-empty done-when)) (conj (str " done_when: " done-when)) (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) -(defn- read-text [state] +(defn ^:private read-text [state] (let [tasks (:tasks state)] (str "Goal: " (or (not-empty (:goal state)) "(none)") "\n" "Summary: " (summary-line-with-total tasks) "\n" @@ -227,29 +227,29 @@ (str/join "\n" (map read-task-line tasks)) "(none)")))) -(defn- todo-details +(defn ^:private todo-details "Structured data for client rendering." [state] (let [{:keys [goal tasks]} state tasks-by-id (task-index tasks) {:keys [done in-progress pending]} (status-counts tasks)] - {:type :todoState + {:type :todo :goal (or goal "") - :inProgressTaskIds (mapv :id (filter #(= :in-progress (:status %)) tasks)) + :in-progress-task-ids (mapv :id (filter #(= :in-progress (:status %)) tasks)) :tasks (mapv (fn [{:keys [id content status priority done-when blocked-by]}] (cond-> {:id id :content content :status (name status) :priority (name priority) - :isBlocked (boolean (active-blockers tasks-by-id id))} - (and (string? done-when) (not-empty done-when)) (assoc :doneWhen done-when) - (seq blocked-by) (assoc :blockedBy (vec (sort blocked-by))))) + :is-blocked (boolean (active-blockers tasks-by-id id))} + (and (string? done-when) (not-empty done-when)) (assoc :done-when done-when) + (seq blocked-by) (assoc :blocked-by (vec (sort blocked-by))))) tasks) - :summary {:done done :inProgress in-progress :pending pending :total (count tasks)}})) + :summary {:done done :in-progress in-progress :pending pending :total (count tasks)}})) -(defn- success [state text] +(defn ^:private success [state text] (assoc (tools.util/single-text-content text) :details (todo-details state))) -(defn- format-tasks-list [tasks] +(defn ^:private format-tasks-list [tasks] (str/join "\n" (map #(format "- #%d: %s" (:id %) (:content %)) tasks))) (defmethod tools.util/tool-call-details-after-invocation :todo @@ -258,11 +258,11 @@ ;; --- Operations --- -(defn- op-read [_arguments {:keys [db chat-id]}] +(defn ^:private op-read [_arguments {:keys [db chat-id]}] (let [state (get-todo db chat-id)] (success state (read-text state)))) -(defn- op-plan [{:strs [goal tasks]} {:keys [db* chat-id]}] +(defn ^:private op-plan [{:strs [goal tasks]} {:keys [db* chat-id]}] (or (require-nonblank "goal" goal) (when-not (and (sequential? tasks) (seq tasks)) (error "plan requires 'tasks' (non-empty array)")) @@ -281,7 +281,7 @@ (success (:state result) (str "TODO created with " (count (get-in result [:state :tasks])) " tasks")))))) -(defn- op-add [{:strs [tasks task] :as _arguments} {:keys [db* chat-id]}] +(defn ^:private op-add [{:strs [tasks task] :as _arguments} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [state] (cond @@ -318,7 +318,7 @@ (def ^:private updatable-keys #{"content" "priority" "done_when" "blocked_by"}) -(defn- op-update [{:strs [id task]} {:keys [db* chat-id]}] +(defn ^:private op-update [{:strs [id task]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [state] (let [[id err] (resolve-id state id)] @@ -367,12 +367,12 @@ (:task-id result) (format-tasks-list [task]))))))) -(defn- set-status +(defn ^:private set-status "Set status on tasks by IDs." [tasks id-set status] (mapv #(if (contains? id-set (:id %)) (assoc % :status status) %) tasks)) -(defn- op-start [{:strs [ids]} {:keys [db* chat-id]}] +(defn ^:private op-start [{:strs [ids]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [state] (let [tasks-by-id (task-index (:tasks state)) @@ -399,7 +399,7 @@ (count started) (format-tasks-list started))))))) -(defn- op-complete [{:strs [ids]} {:keys [db* chat-id]}] +(defn ^:private op-complete [{:strs [ids]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [state] (let [tasks-by-id (task-index (:tasks state)) @@ -418,24 +418,24 @@ :ids ids}))))] (if (:error result) result - (let [state (:state result) - tasks-by-id (task-index (:tasks state)) - ids (set (:ids result)) - unblocked (keep (fn [t] - (when (and (= :pending (:status t)) - (some ids (:blocked-by t)) - (not (active-blockers tasks-by-id (:id t)))) - (:id t))) - (:tasks state)) - completed (filter #(contains? ids (:id %)) (:tasks state))] - (success state - (str (format "Completed %d task(s):\n%s" - (count completed) - (format-tasks-list completed)) - (when (seq unblocked) - (format "\nUnblocked: %s" (str/join ", " unblocked))))))))) - -(defn- op-delete [{:strs [ids]} {:keys [db* chat-id]}] + (let [state (:state result) + tasks-by-id (task-index (:tasks state)) + ids (set (:ids result)) + unblocked (keep (fn [t] + (when (and (= :pending (:status t)) + (some ids (:blocked-by t)) + (not (active-blockers tasks-by-id (:id t)))) + (:id t))) + (:tasks state)) + completed (filter #(contains? ids (:id %)) (:tasks state))] + (success state + (str (format "Completed %d task(s):\n%s" + (count completed) + (format-tasks-list completed)) + (when (seq unblocked) + (format "\nUnblocked: %s" (str/join ", " unblocked))))))))) + +(defn ^:private op-delete [{:strs [ids]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [state] (let [[ids err] (resolve-ids state ids)] @@ -454,7 +454,7 @@ (count (:deleted result)) (format-tasks-list (:deleted result))))))) -(defn- op-clear [_arguments {:keys [db* chat-id]}] +(defn ^:private op-clear [_arguments {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id (fn [_] {:state empty-todo}))] (if (:error result) result @@ -472,7 +472,7 @@ "delete" op-delete "clear" op-clear}) -(defn- execute-todo [arguments ctx] +(defn ^:private execute-todo [arguments ctx] (let [op (get arguments "op") handler (get ops op)] (if handler diff --git a/test/eca/features/tools/todo_test.clj b/test/eca/features/tools/todo_test.clj index 01c54bd6e..154890c78 100644 --- a/test/eca/features/tools/todo_test.clj +++ b/test/eca/features/tools/todo_test.clj @@ -43,7 +43,7 @@ (testing "reads empty state" (let [result (handler {"op" "read"} {:db {} :chat-id "c1"})] (is (not (:error result))) - (is (match? {:details {:type :todoState :goal "" :tasks []}} result)))) + (is (match? {:details {:type :todo :goal "" :tasks []}} result)))) (testing "includes structured details" (let [db {:chats {"c1" {:todo {:goal "Test Goal" @@ -54,7 +54,7 @@ :done-when "criteria" :blocked-by #{}}]}}}} result (handler {"op" "read"} {:db db :chat-id "c1"})] - (is (match? {:details {:type :todoState + (is (match? {:details {:type :todo :goal "Test Goal" :inProgressTaskIds [1] :tasks [{:id 1 @@ -455,7 +455,7 @@ (deftest todo-tool-call-details-test (testing "todo details are propagated to tool call details for clients" (let [result {:error false - :details {:type :todoState + :details {:type :todo :goal "Goal" :tasks [] :summary {:done 0 :inProgress 0 :pending 0 :total 0}} From ef2e70a5f36cd866b8b084783b88299cc90520a6 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 4 Mar 2026 10:23:45 -0300 Subject: [PATCH 3/8] Fix tests --- test/eca/features/tools/todo_test.clj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/eca/features/tools/todo_test.clj b/test/eca/features/tools/todo_test.clj index 154890c78..95d202f9c 100644 --- a/test/eca/features/tools/todo_test.clj +++ b/test/eca/features/tools/todo_test.clj @@ -56,15 +56,15 @@ result (handler {"op" "read"} {:db db :chat-id "c1"})] (is (match? {:details {:type :todo :goal "Test Goal" - :inProgressTaskIds [1] + :in-progress-task-ids [1] :tasks [{:id 1 :content "Task 1" :status "in-progress" :priority "high" - :isBlocked false - :doneWhen "criteria"}] + :is-blocked false + :done-when "criteria"}] :summary {:done 0 - :inProgress 1 + :in-progress 1 :pending 0 :total 1}}} result)))) @@ -458,7 +458,7 @@ :details {:type :todo :goal "Goal" :tasks [] - :summary {:done 0 :inProgress 0 :pending 0 :total 0}} + :summary {:done 0 :in-progress 0 :pending 0 :total 0}} :contents [{:type :text :text "TODO created"}]} details (f.tools/tool-call-details-after-invocation :todo From 302407406b87643d8eff711037651dd8c7ffa991 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 4 Mar 2026 10:58:42 -0300 Subject: [PATCH 4/8] Auto allow todo tools by default --- src/eca/config.clj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/eca/config.clj b/src/eca/config.clj index 0fe31e217..32fd349de 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -112,6 +112,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} + "eca__todo" {} "eca__spawn_agent" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} @@ -132,7 +133,8 @@ "eca__directory_tree" {} "eca__grep" {} "eca__editor_diagnostics" {} - "eca__skill" {}} + "eca__skill" {} + "eca__todo" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "general" {:mode "subagent" @@ -160,6 +162,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} + "eca__todo" {} "eca__spawn_agent" {}} :ask {} :deny {}} From 630c22beeec6851a1fc0f0a458c24865c158f6f8 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Wed, 4 Mar 2026 16:35:35 +0100 Subject: [PATCH 5/8] streamline tool schema and improve LLM workflow instructions - Remove the `goal` field from the TODO state and `plan` operation, as discrete tasks often do not share a single high-level objective. - Unify the JSON tool parameter schema to use `snake_case` (e.g., `active_summary`) to match existing fields like `blocked_by`. - Restrict the `active_summary` parameter exclusively to the `start` operation, where it semantically belongs. - Update agent prompts (`todo.md`, `code_agent.md`) to explicitly forbid batch-starting tasks unless performing genuinely parallel work (e.g., dispatching subagents). - Update all corresponding tool tests to reflect the new state shape and validation logic. --- resources/prompts/code_agent.md | 17 +- resources/prompts/tools/todo.md | 27 ++- src/eca/db.clj | 8 +- src/eca/features/tools/todo.clj | 175 +++++++++-------- test/eca/features/tools/todo_test.clj | 259 ++++++++++++++------------ 5 files changed, 256 insertions(+), 230 deletions(-) diff --git a/resources/prompts/code_agent.md b/resources/prompts/code_agent.md index 8ea5cbf1c..66297f293 100644 --- a/resources/prompts/code_agent.md +++ b/resources/prompts/code_agent.md @@ -34,15 +34,14 @@ You have tools at your disposal to solve the coding task. Follow these rules reg You have access to a `eca__todo` tool for tracking multi-step work within this chat. ### Workflow: -1. Use `plan` to create TODO with goal and tasks -2. Use `start` before working on a task (marks it as in_progress) -3. Use `complete` only for tasks that are actually finished; for each targeted task that has `done_when`, verify it first. +1. Use `plan` to create TODO with initial tasks +2. Use `start` before working on a task (marks it as in_progress and requires an `active_summary`) +3. Use `complete` only for tasks that are actually finished; for each targeted task, verify its acceptance criteria first. 4. Use `add` if you discover additional work -5. When a plan is fully completed and no further work is needed for the current goal, always use the `clear` operation to clean up the workspace. -6. Delegate focused work to subagents when helpful. Work sequentially by default; batch operations ONLY for tasks executing simultaneously. +5. When a plan is fully completed and no further work is needed, always use the `clear` operation to clean up the workspace. +6. When helpful, delegate focused work to subagents. You MAY start multiple independent tasks in parallel (keep the number of in_progress tasks small), then update/complete the TODO based on subagent outputs. Prefer that only the main agent updates the TODO; subagents should focus on producing outputs. -### done_when guidance: -- `done_when` is optional. -- Prefer setting it for non-trivial tasks where completion should be objectively verifiable. -- For trivial tasks, you may omit it to keep tracking lightweight. +### Task tracking guidance: +- Make sure to add acceptance criteria inside your task `description`. +- Objective verification of acceptance criteria is required for completion. {% endif %} diff --git a/resources/prompts/tools/todo.md b/resources/prompts/tools/todo.md index 030c1cfd5..0cd8a6bd4 100644 --- a/resources/prompts/tools/todo.md +++ b/resources/prompts/tools/todo.md @@ -13,29 +13,28 @@ When NOT to Use: Operations: - read: View current TODO state -- plan: Create/replace TODO with goal and tasks (required: goal, tasks) +- plan: Create/replace TODO with initial tasks (required: tasks) - add: Append task(s) to existing TODO -- update: Modify a single task metadata by `id` (content, priority, done_when, blocked_by) — cannot change status -- start: Begin work on tasks by `ids` (sets to in_progress; rejects blocked or done tasks) -- complete: Mark tasks by `ids` as done (verify done_when criteria first) +- update: Modify a single task metadata by `id` (subject, description, priority, blocked_by) — cannot change status +- start: Begin work on tasks by `ids` (sets to in_progress; rejects blocked or done tasks). Requires `active_summary` to summarize what you are about to do. +- complete: Mark tasks by `ids` as done (verify acceptance criteria in description first) - delete: Remove tasks by `ids` -- clear: Reset entire TODO (removes goal and all tasks) +- clear: Reset entire TODO (removes all tasks) Workflow: -1. Use 'plan' to create TODO with goal and initial tasks -2. Use 'start' before working on a task — marks it as in_progress +1. Use 'plan' to create TODO with initial tasks +2. Use 'start' before working on a task — marks it as in_progress and requires an `active_summary` 3. Work sequentially by default. Batch 'start' or 'complete' operations (using multiple 'ids') ONLY for independent tasks being executed simultaneously (e.g., via subagents). -4. Use 'complete' only for tasks that are actually finished; for each targeted task that has `done_when`, verify it first — the response tells you which tasks got unblocked +4. Use 'complete' only for tasks that are actually finished; verify acceptance criteria first — the response tells you which tasks got unblocked 5. Use 'add' if you discover additional work during execution -6. When a plan is fully completed and no further work is needed for the current goal, always use the 'clear' operation to clean up the workspace. +6. When a plan is fully completed and no further work is needed, always use the 'clear' operation to clean up the workspace. -How to use done_when: -- `done_when` is optional. -- Use it for non-trivial tasks where completion must be verifiable (tests passing, behavior implemented, specific file changes, etc.). -- For trivial/obvious tasks, you may omit `done_when` to avoid overhead. +Task Schema: +- `subject`: A brief, actionable title in imperative form (e.g. "Fix login bug") +- `description`: Detailed description of what needs to be done, including context and acceptance criteria Task Completion Integrity: - Mark tasks complete as soon as they are finished. -- If a task has `done_when`, ONLY mark it as completed when ALL `done_when` criteria are actually met (for each task in a batch). +- ONLY mark it as completed when ALL acceptance criteria from the `description` are actually met (for each task in a batch). - Do NOT complete if: tests failing, implementation partial, or errors unresolved. - When completing reveals follow-up work, use 'add' to append new tasks. diff --git a/src/eca/db.clj b/src/eca/db.clj index e0bbba58f..8096dc955 100644 --- a/src/eca/db.clj +++ b/src/eca/db.clj @@ -60,13 +60,13 @@ :messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason") :content (or :string [::any-map]) ;; string for simple text, map/vector for structured content :content-id :string}] - :todo {:goal :string - :next-id :number + :todo {:next-id :number + :active-summary (or :string nil) :tasks [{:id :number - :content :string + :subject :string + :description :string :status (or :pending :in-progress :done) :priority (or :high :medium :low) - :done-when (or :string nil) :blocked-by #{:number}}]} :tool-calls {"" {:status (or :initial :preparing :check-approval :waiting-approval diff --git a/src/eca/features/tools/todo.clj b/src/eca/features/tools/todo.clj index cb14ab095..b12273883 100644 --- a/src/eca/features/tools/todo.clj +++ b/src/eca/features/tools/todo.clj @@ -7,7 +7,7 @@ ;; --- Helpers --- -(def ^:private empty-todo {:goal "" :next-id 1 :tasks []}) +(def ^:private empty-todo {:next-id 1 :active-summary nil :tasks []}) (def ^:private valid-priorities #{:high :medium :low}) (defn ^:private error [msg] @@ -73,10 +73,6 @@ (contains? valid-priorities (keyword priority))) (error (format "Invalid priority: %s. Allowed: high, medium, low" priority)))) -(defn ^:private validate-done-when [done-when] - (when-not (or (nil? done-when) (string? done-when)) - (error "done_when must be a string when provided"))) - (defn ^:private resolve-ids "Validate task IDs. Returns [ids error]." [state raw-ids] @@ -159,23 +155,22 @@ (defn ^:private make-task "Build a task map from string-keyed JSON input. Returns {:task ...} or error response." [raw-task id allowed-ids] - (let [{:strs [content priority blocked_by done_when]} raw-task] - (or (require-nonblank "content" content) + (let [{:strs [subject description priority blocked_by]} raw-task] + (or (require-nonblank "subject" subject) + (require-nonblank "description" description) (when (contains? raw-task "status") (error "Cannot set status when creating tasks. New tasks always start as pending; use 'start' or 'complete'.")) (when (contains? raw-task "priority") (validate-priority priority)) - (when (contains? raw-task "done_when") - (validate-done-when done_when)) (let [[blocked-by err] (validate-blocked-by blocked_by allowed-ids id)] (or err {:task {:id id - :content content + :subject subject + :description description :status :pending :priority (if (contains? raw-task "priority") (keyword priority) :medium) - :done-when done_when :blocked-by blocked-by}}))))) (defn ^:private build-tasks @@ -211,46 +206,54 @@ (defn ^:private summary-line-with-total [tasks] (format "%s, %d total" (summary-line tasks) (count tasks))) -(defn ^:private read-task-line [{:keys [id content status priority done-when blocked-by]}] - (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) content)] +(defn ^:private read-task-line-full [{:keys [id subject description status priority blocked-by]}] + (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) subject)] + (str/join "\n" + (cond-> [base] + true (conj (str " description: " description)) + (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) + +(defn ^:private read-task-line-short [{:keys [id subject status priority blocked-by]}] + (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) subject)] (str/join "\n" (cond-> [base] - (and (string? done-when) (not-empty done-when)) (conj (str " done_when: " done-when)) (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) (defn ^:private read-text [state] (let [tasks (:tasks state)] - (str "Goal: " (or (not-empty (:goal state)) "(none)") "\n" + (str (when-let [summary (:active-summary state)] + (str "Active Summary: " summary "\n")) "Summary: " (summary-line-with-total tasks) "\n" "Tasks:\n" (if (seq tasks) - (str/join "\n" (map read-task-line tasks)) + (str/join "\n" (map read-task-line-full tasks)) "(none)")))) (defn ^:private todo-details "Structured data for client rendering." [state] - (let [{:keys [goal tasks]} state + (let [{:keys [tasks active-summary]} state tasks-by-id (task-index tasks) {:keys [done in-progress pending]} (status-counts tasks)] - {:type :todo - :goal (or goal "") - :in-progress-task-ids (mapv :id (filter #(= :in-progress (:status %)) tasks)) - :tasks (mapv (fn [{:keys [id content status priority done-when blocked-by]}] - (cond-> {:id id :content content - :status (name status) - :priority (name priority) - :is-blocked (boolean (active-blockers tasks-by-id id))} - (and (string? done-when) (not-empty done-when)) (assoc :done-when done-when) - (seq blocked-by) (assoc :blocked-by (vec (sort blocked-by))))) - tasks) - :summary {:done done :in-progress in-progress :pending pending :total (count tasks)}})) + (cond-> {:type :todo + :in-progress-task-ids (mapv :id (filter #(= :in-progress (:status %)) tasks)) + :tasks (mapv (fn [{:keys [id subject description status priority blocked-by]}] + (cond-> {:id id + :subject subject + :description description + :status (name status) + :priority (name priority) + :is-blocked (boolean (active-blockers tasks-by-id id))} + (seq blocked-by) (assoc :blocked-by (vec (sort blocked-by))))) + tasks) + :summary {:done done :in-progress in-progress :pending pending :total (count tasks)}} + active-summary (assoc :active-summary active-summary)))) (defn ^:private success [state text] (assoc (tools.util/single-text-content text) :details (todo-details state))) -(defn ^:private format-tasks-list [tasks] - (str/join "\n" (map #(format "- #%d: %s" (:id %) (:content %)) tasks))) +(defn ^:private format-tasks-list [tasks & [full?]] + (str/join "\n" (map (if full? read-task-line-full read-task-line-short) tasks))) (defmethod tools.util/tool-call-details-after-invocation :todo [_name _arguments before-details result _ctx] @@ -262,18 +265,16 @@ (let [state (get-todo db chat-id)] (success state (read-text state)))) -(defn ^:private op-plan [{:strs [goal tasks]} {:keys [db* chat-id]}] - (or (require-nonblank "goal" goal) - (when-not (and (sequential? tasks) (seq tasks)) +(defn ^:private op-plan [{:strs [tasks]} {:keys [db* chat-id]}] + (or (when-not (and (sequential? tasks) (seq tasks)) (error "plan requires 'tasks' (non-empty array)")) (let [result (mutate-todo! db* chat-id (fn [_state] - (let [fresh (assoc empty-todo :goal goal) - built (build-tasks fresh tasks)] + (let [built (build-tasks empty-todo tasks)] (if (:error built) built (or (detect-cycle (:tasks built)) - {:state (assoc fresh + {:state (assoc empty-todo :tasks (:tasks built) :next-id (inc (count (:tasks built))))})))))] (if (:error result) @@ -316,7 +317,7 @@ (success (:state result) (format "Added %d task(s): %s" (count added) (str/join ", " ids))))))) -(def ^:private updatable-keys #{"content" "priority" "done_when" "blocked_by"}) +(def ^:private updatable-keys #{"subject" "description" "priority" "blocked_by"}) (defn ^:private op-update [{:strs [id task]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id @@ -334,13 +335,13 @@ (error "No updatable fields in task") :else - (let [{:strs [content priority done_when blocked_by]} task] - (or (when (contains? task "content") - (require-nonblank "content" content)) + (let [{:strs [subject description priority blocked_by]} task] + (or (when (contains? task "subject") + (require-nonblank "subject" subject)) + (when (contains? task "description") + (require-nonblank "description" description)) (when (contains? task "priority") (validate-priority priority)) - (when (contains? task "done_when") - (validate-done-when done_when)) (let [[blocked-by err] (if (contains? task "blocked_by") (validate-blocked-by blocked_by @@ -351,9 +352,9 @@ (let [new-tasks (mapv (fn [t] (if (= id (:id t)) (cond-> t - (contains? task "content") (assoc :content content) - (contains? task "priority") (assoc :priority (keyword priority)) - (contains? task "done_when") (assoc :done-when done_when) + (contains? task "subject") (assoc :subject subject) + (contains? task "description") (assoc :description description) + (contains? task "priority") (assoc :priority (keyword priority)) (contains? task "blocked_by") (assoc :blocked-by blocked-by)) t)) (:tasks state))] @@ -372,32 +373,40 @@ [tasks id-set status] (mapv #(if (contains? id-set (:id %)) (assoc % :status status) %) tasks)) -(defn ^:private op-start [{:strs [ids]} {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id - (fn [state] - (let [tasks-by-id (task-index (:tasks state)) - [ids err] (resolve-ids state ids)] - (or err - (some (fn [id] - (let [task (get tasks-by-id id)] - (cond - (= :done (:status task)) - (error (format "Cannot start task %d: already done" id)) - - (seq (active-blockers tasks-by-id id)) - (error (format "Cannot start task %d: blocked by %s" - id (str/join ", " (active-blockers tasks-by-id id))))))) - ids) - {:state (assoc state :tasks (set-status (:tasks state) (set ids) :in-progress)) - :ids ids}))))] - (if (:error result) - result - (let [state (:state result) - started (filter #(contains? (set (:ids result)) (:id %)) (:tasks state))] - (success state - (format "Started %d task(s):\n%s" - (count started) - (format-tasks-list started))))))) +(defn ^:private op-start [{:strs [ids active_summary]} {:keys [db* chat-id]}] + (or (require-nonblank "active_summary" active_summary) + (let [result (mutate-todo! db* chat-id + (fn [state] + (let [tasks-by-id (task-index (:tasks state)) + [ids err] (resolve-ids state ids)] + (or err + (some (fn [id] + (let [task (get tasks-by-id id)] + (cond + (= :done (:status task)) + (error (format "Cannot start task %d: already done" id)) + + (seq (active-blockers tasks-by-id id)) + (error (format "Cannot start task %d: blocked by %s" + id (str/join ", " (active-blockers tasks-by-id id))))))) + ids) + {:state (-> state + (assoc :tasks (set-status (:tasks state) (set ids) :in-progress)) + (assoc :active-summary active_summary)) + :ids ids}))))] + (if (:error result) + result + (let [state (:state result) + started (filter #(contains? (set (:ids result)) (:id %)) (:tasks state))] + (success state + (format "Started %d task(s):\n%s" + (count started) + (format-tasks-list started true)))))))) + +(defn ^:private clean-summary-if-no-in-progress [state] + (if (empty? (filter #(= :in-progress (:status %)) (:tasks state))) + (assoc state :active-summary nil) + state)) (defn ^:private op-complete [{:strs [ids]} {:keys [db* chat-id]}] (let [result (mutate-todo! db* chat-id @@ -414,7 +423,9 @@ id (str/join ", " blockers)))))) ids) - {:state (assoc state :tasks (set-status (:tasks state) (set ids) :done)) + {:state (-> state + (assoc :tasks (set-status (:tasks state) (set ids) :done)) + clean-summary-if-no-in-progress) :ids ids}))))] (if (:error result) result @@ -444,7 +455,9 @@ remaining (->> (:tasks state) (remove #(contains? id-set (:id %))) (mapv #(update % :blocked-by (fn [b] (reduce disj b ids)))))] - {:state (assoc state :tasks remaining) + {:state (-> state + (assoc :tasks remaining) + clean-summary-if-no-in-progress) :ids ids :deleted (filter #(contains? id-set (:id %)) (:tasks state))})))))] (if (:error result) @@ -491,23 +504,23 @@ :ids {:type "array" :items {:type "integer"} :description "Task IDs (required for start/complete/delete)"} - :goal {:type "string" - :description "Overall objective (required for plan)"} + :active_summary {:type "string" + :description "Summary of what will be done in the current active session. Required for start operation."} :task {:type "object" :description "Single task data (for add/update)" - :properties {:content {:type "string" :description "Task description"} + :properties {:subject {:type "string" :description "Task subject/title (required)"} + :description {:type "string" :description "Detailed description of the task (required)"} :priority {:type "string" :enum ["high" "medium" "low"] :description "Task priority (default: medium)"} - :done_when {:type "string" :description "Optional acceptance criteria (recommended for non-trivial tasks)"} :blocked_by {:type "array" :items {:type "integer"} :description "IDs of blocking tasks"}}} :tasks {:type "array" :description "Array of tasks (required for plan, alternative for add)" :items {:type "object" - :properties {:content {:type "string" :description "Task description (required)"} + :properties {:subject {:type "string" :description "Task subject/title (required)"} + :description {:type "string" :description "Detailed description of the task (required)"} :priority {:type "string" :enum ["high" "medium" "low"]} - :done_when {:type "string" :description "Optional acceptance criteria (recommended for non-trivial tasks)"} :blocked_by {:type "array" :items {:type "integer"}}} - :required ["content"]}}} + :required ["subject" "description"]}}} :required ["op"]} :handler execute-todo}}) diff --git a/test/eca/features/tools/todo_test.clj b/test/eca/features/tools/todo_test.clj index 95d202f9c..ee68dd7ab 100644 --- a/test/eca/features/tools/todo_test.clj +++ b/test/eca/features/tools/todo_test.clj @@ -13,26 +13,26 @@ (testing "each chat has its own TODO" (let [db* (atom {}) handler (get-in todo/definitions ["todo" :handler])] - (handler {"op" "add" "task" {"content" "Chat 1 Task"}} {:db* db* :chat-id "chat-1"}) - (handler {"op" "add" "task" {"content" "Chat 2 Task"}} {:db* db* :chat-id "chat-2"}) - (is (= ["Chat 1 Task"] (map :content (:tasks (todo/get-todo @db* "chat-1"))))) - (is (= ["Chat 2 Task"] (map :content (:tasks (todo/get-todo @db* "chat-2"))))))) + (handler {"op" "add" "task" {"subject" "Chat 1 Task" "description" "Desc"}} {:db* db* :chat-id "chat-1"}) + (handler {"op" "add" "task" {"subject" "Chat 2 Task" "description" "Desc"}} {:db* db* :chat-id "chat-2"}) + (is (= ["Chat 1 Task"] (map :subject (:tasks (todo/get-todo @db* "chat-1"))))) + (is (= ["Chat 2 Task"] (map :subject (:tasks (todo/get-todo @db* "chat-2"))))))) (testing "empty chat returns empty TODO" - (is (= {:goal "" :next-id 1 :tasks []} + (is (= {:next-id 1 :active-summary nil :tasks []} (todo/get-todo {} "nonexistent-chat"))))) ;; --- State Access Tests --- (deftest get-todo-test (testing "returns empty todo for missing chat state" - (is (= {:goal "" :next-id 1 :tasks []} + (is (= {:next-id 1 :active-summary nil :tasks []} (todo/get-todo {} "missing-chat")))) (testing "returns stored todo state as-is" - (let [state {:goal "G" - :next-id 2 - :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]} + (let [state {:next-id 2 + :active-summary "Doing stuff" + :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]} db {:chats {"c1" {:todo state}}}] (is (= state (todo/get-todo db "c1")))))) @@ -43,26 +43,26 @@ (testing "reads empty state" (let [result (handler {"op" "read"} {:db {} :chat-id "c1"})] (is (not (:error result))) - (is (match? {:details {:type :todo :goal "" :tasks []}} result)))) + (is (match? {:details {:type :todo :tasks []}} result)))) (testing "includes structured details" - (let [db {:chats {"c1" {:todo {:goal "Test Goal" + (let [db {:chats {"c1" {:todo {:active-summary "My active task" :tasks [{:id 1 - :content "Task 1" + :subject "Task 1" + :description "Desc 1" :status :in-progress :priority :high - :done-when "criteria" :blocked-by #{}}]}}}} result (handler {"op" "read"} {:db db :chat-id "c1"})] (is (match? {:details {:type :todo - :goal "Test Goal" + :active-summary "My active task" :in-progress-task-ids [1] :tasks [{:id 1 - :content "Task 1" + :subject "Task 1" + :description "Desc 1" :status "in-progress" :priority "high" - :is-blocked false - :done-when "criteria"}] + :is-blocked false}] :summary {:done 0 :in-progress 1 :pending 0 @@ -70,25 +70,26 @@ result)))) (testing "read returns full task list text for llm" - (let [db {:chats {"c1" {:todo {:goal "Test Goal" + (let [db {:chats {"c1" {:todo {:active-summary "My active task" :tasks [{:id 1 - :content "Task 1" + :subject "Task 1" + :description "Desc 1" :status :in-progress :priority :high - :done-when "criteria" :blocked-by #{}} {:id 2 - :content "Task 2" + :subject "Task 2" + :description "Desc 2" :status :pending :priority :medium :blocked-by #{1}}]}}}} result (handler {"op" "read"} {:db db :chat-id "c1"}) text (get-in result [:contents 0 :text])] (is (string? text)) - (is (re-find #"Goal: Test Goal" text)) + (is (re-find #"Active Summary: My active task" text)) (is (re-find #"Summary: 0 done, 1 in progress, 1 pending, 2 total" text)) (is (re-find #"#1 \[in-progress\] \[high\] Task 1" text)) - (is (re-find #"done_when: criteria" text)) + (is (re-find #"description: Desc 1" text)) (is (re-find #"#2 \[pending\] \[medium\] Task 2" text)) (is (re-find #"blocked_by: 1" text)))))) @@ -96,65 +97,49 @@ (deftest op-plan-test (let [handler (get-in todo/definitions ["todo" :handler])] - (testing "creates TODO with goal and tasks" + (testing "creates TODO with tasks" (let [db* (atom {}) result (handler {"op" "plan" - "goal" "My Goal" - "tasks" [{"content" "Task 1"} - {"content" "Task 2" "blocked_by" [1]}]} + "tasks" [{"subject" "Task 1" "description" "Desc 1"} + {"subject" "Task 2" "description" "Desc 2" "blocked_by" [1]}]} {:db* db* :chat-id "c1"})] (is (not (:error result))) - (is (= "My Goal" (get-in @db* [:chats "c1" :todo :goal]))) (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= ["Task 1" "Task 2"] (map :content (get-in @db* [:chats "c1" :todo :tasks])))) + (is (= ["Task 1" "Task 2"] (map :subject (get-in @db* [:chats "c1" :todo :tasks])))) (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) (is (= :medium (get-in @db* [:chats "c1" :todo :tasks 0 :priority]))))) (testing "replaces existing TODO completely" - (let [db* (atom {:chats {"c1" {:todo {:goal "Old Goal" - :tasks [{:id 1 :content "Old Task" :status :in-progress} - {:id 2 :content "Old Task 2" :status :pending}] + (let [db* (atom {:chats {"c1" {:todo {:active-summary "Old summary" + :tasks [{:id 1 :subject "Old Task" :description "Old D" :status :in-progress} + {:id 2 :subject "Old Task 2" :description "Old D2" :status :pending}] :next-id 3}}}})] (handler {"op" "plan" - "goal" "New Goal" - "tasks" [{"content" "New Task"}]} + "tasks" [{"subject" "New Task" "description" "New D"}]} {:db* db* :chat-id "c1"}) - (is (= "New Goal" (get-in @db* [:chats "c1" :todo :goal]))) - (is (not-any? #(= "Old Task" (:content %)) + (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))) + (is (not-any? #(= "Old Task" (:subject %)) (get-in @db* [:chats "c1" :todo :tasks]))) (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= "New Task" (get-in @db* [:chats "c1" :todo :tasks 0 :content]))) + (is (= "New Task" (get-in @db* [:chats "c1" :todo :tasks 0 :subject]))) (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) - (testing "requires goal" - (let [db* (atom {}) - result (handler {"op" "plan" "tasks" [{"content" "Task 1"}]} {:db* db* :chat-id "c1"})] - (is (:error result)) - (is (match? {:contents [{:type :text :text #"goal must be a non-blank string"}]} result)))) - - (testing "requires non-blank goal" - (let [db* (atom {}) - result (handler {"op" "plan" "goal" " " "tasks" [{"content" "Task 1"}]} {:db* db* :chat-id "c1"})] - (is (:error result)) - (is (match? {:contents [{:type :text :text #"goal must be a non-blank string"}]} result)))) - (testing "requires tasks" (let [db* (atom {}) - result (handler {"op" "plan" "goal" "My Goal"} {:db* db* :chat-id "c1"})] + result (handler {"op" "plan"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"requires 'tasks'"}]} result)))) (testing "requires non-empty tasks array" (let [db* (atom {}) - result (handler {"op" "plan" "goal" "My Goal" "tasks" []} {:db* db* :chat-id "c1"})] + result (handler {"op" "plan" "tasks" []} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "rejects status in plan task payload" (let [db* (atom {}) result (handler {"op" "plan" - "goal" "My Goal" - "tasks" [{"content" "Task 1" "status" "done"}]} + "tasks" [{"subject" "Task 1" "description" "D1" "status" "done"}]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot set status when creating tasks"}]} result)))))) @@ -165,26 +150,26 @@ (let [handler (get-in todo/definitions ["todo" :handler])] (testing "adds single task" (let [db* (atom {})] - (handler {"op" "add" "task" {"content" "Task 1"}} {:db* db* :chat-id "c1"}) + (handler {"op" "add" "task" {"subject" "Task 1" "description" "Desc 1"}} {:db* db* :chat-id "c1"}) (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= "Task 1" (get-in @db* [:chats "c1" :todo :tasks 0 :content]))) + (is (= "Task 1" (get-in @db* [:chats "c1" :todo :tasks 0 :subject]))) (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) (testing "adds batch of tasks" (let [db* (atom {})] - (handler {"op" "add" "tasks" [{"content" "T1"} {"content" "T2"}]} {:db* db* :chat-id "c1"}) + (handler {"op" "add" "tasks" [{"subject" "T1" "description" "D1"} {"subject" "T2" "description" "D2"}]} {:db* db* :chat-id "c1"}) (let [tasks (get-in @db* [:chats "c1" :todo :tasks])] (is (= 2 (count tasks))) (is (= [1 2] (map :id tasks)))))) - (testing "validates required content" + (testing "validates required subject" (let [db* (atom {}) - result (handler {"op" "add" "task" {"content" ""}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "" "description" "D1"}} {:db* db* :chat-id "c1"})] (is (:error result)))) (testing "rejects status in add payload" (let [db* (atom {}) - result (handler {"op" "add" "task" {"content" "Task 1" "status" "done"}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "Task 1" "description" "D1" "status" "done"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot set status when creating tasks"}]} result)))) @@ -199,28 +184,28 @@ (deftest op-update-test (let [handler (get-in todo/definitions ["todo" :handler])] (testing "updates task metadata" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "Old" :status :pending :priority :medium :blocked-by #{}}]}}}})] - (handler {"op" "update" "id" 1 "task" {"content" "New" "priority" "high"}} {:db* db* :chat-id "c1"}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "Old" :description "Old D" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "update" "id" 1 "task" {"subject" "New" "priority" "high"}} {:db* db* :chat-id "c1"}) (let [task (first (get-in @db* [:chats "c1" :todo :tasks]))] - (is (= "New" (:content task))) + (is (= "New" (:subject task))) (is (= :high (:priority task)))))) (testing "rejects status changes via update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"status" "done"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot set status via update"}]} result)) (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))))) (testing "rejects empty updates" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) result (handler {"op" "update" "id" 1 "task" {}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"No updatable fields"}]} result)))) (testing "returns an error response for unknown id" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending}]}}}}) - result (handler {"op" "update" "id" 999 "task" {"content" "New"}} {:db* db* :chat-id "c1"})] + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) + result (handler {"op" "update" "id" 999 "task" {"subject" "New"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"not found"}]} result)))))) @@ -229,42 +214,56 @@ (deftest op-start-test (let [handler (get-in todo/definitions ["todo" :handler])] (testing "starts pending tasks by ids" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{}}]}}}})] - (handler {"op" "start" "ids" [1 2]} {:db* db* :chat-id "c1"}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "start" "ids" [1 2] "active_summary" "Doing T1 and T2"} {:db* db* :chat-id "c1"}) (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) + (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :todo :active-summary]))))) (testing "does not demote other in-progress tasks" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{}}]}}}})] - (handler {"op" "start" "ids" [2]} {:db* db* :chat-id "c1"}) + (let [db* (atom {:chats {"c1" {:todo {:active-summary "Doing T1" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "start" "ids" [2] "active_summary" "Doing T1 and T2"} {:db* db* :chat-id "c1"}) (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) + (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :todo :active-summary]))))) (testing "requires ids for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) - result (handler {"op" "start"} {:db* db* :chat-id "c1"})] + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) - (testing "rejects starting a done task" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :done :priority :medium :blocked-by #{}}]}}}}) + (testing "requires active_summary for start" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "ids" [1]} {:db* db* :chat-id "c1"})] (is (:error result)) + (is (match? {:contents [{:type :text :text #"active_summary must be a non-blank string"}]} result)))) + + (testing "rejects blank active_summary for start" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "ids" [1] "active_summary" " "} {:db* db* :chat-id "c1"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"active_summary must be a non-blank string"}]} result)))) + + (testing "rejects starting a done task" + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :done :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "ids" [1] "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] + (is (:error result)) (is (match? {:contents [{:type :text :text #"already done"}]} result)))) (testing "rejects starting a blocked task" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) - result (handler {"op" "start" "ids" [2]} {:db* db* :chat-id "c1"})] + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + result (handler {"op" "start" "ids" [2] "active_summary" "Doing T2"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked by 1"}]} result)) (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) (testing "rejects duplicate ids for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) - result (handler {"op" "start" "ids" [1 1]} {:db* db* :chat-id "c1"})] + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + result (handler {"op" "start" "ids" [1 1] "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -273,34 +272,43 @@ (deftest op-complete-test (let [handler (get-in todo/definitions ["todo" :handler])] (testing "completes tasks by ids" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] + (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "complete" "ids" [1 2]} {:db* db* :chat-id "c1"}) (is (= :done (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :done (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + (is (= :done (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) + (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))) + + (testing "leaves active summary if tasks are in-progress" + (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "complete" "ids" [1]} {:db* db* :chat-id "c1"}) + (is (= :done (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) + (is (= "My task" (get-in @db* [:chats "c1" :todo :active-summary]))))) (testing "shows unblocked tasks" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :in-progress :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "complete" "ids" [1]} {:db* db* :chat-id "c1"})] (is (match? {:contents [{:type :text :text #"Unblocked"}]} result)))) (testing "rejects blocked tasks for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "complete" "ids" [2]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot complete task 2: blocked by 1"}]} result)) (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) (testing "requires ids for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "complete"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "rejects duplicate ids for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "complete" "ids" [1 1]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -310,22 +318,34 @@ (deftest op-delete-test (let [handler (get-in todo/definitions ["todo" :handler])] (testing "deletes tasks by ids and clears blocked_by references" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}} - {:id 3 :content "T3" :status :pending :priority :medium :blocked-by #{1 2}}]}}}})] + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}} + {:id 3 :subject "T3" :description "D3" :status :pending :priority :medium :blocked-by #{1 2}}]}}}})] (handler {"op" "delete" "ids" [1 2]} {:db* db* :chat-id "c1"}) (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) (is (= 3 (get-in @db* [:chats "c1" :todo :tasks 0 :id]))) (is (empty? (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) + (testing "deletes tasks clears active-summary when no in-progress remaining" + (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}})] + (handler {"op" "delete" "ids" [1]} {:db* db* :chat-id "c1"}) + (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))) + + (testing "delete leaves active-summary when in-progress task remains" + (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] + (handler {"op" "delete" "ids" [1]} {:db* db* :chat-id "c1"}) + (is (= "My task" (get-in @db* [:chats "c1" :todo :active-summary]))))) + (testing "requires ids for delete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "delete"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "rejects duplicate ids for delete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "delete" "ids" [1 1]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -335,11 +355,11 @@ (deftest op-clear-test (let [handler (get-in todo/definitions ["todo" :handler])] (testing "resets to empty state" - (let [db* (atom {:chats {"c1" {:todo {:goal "G" :tasks [{:id 1}] :next-id 2}}}})] + (let [db* (atom {:chats {"c1" {:todo {:active-summary "Summary" :tasks [{:id 1}] :next-id 2}}}})] (handler {"op" "clear"} {:db* db* :chat-id "c1"}) (is (empty? (get-in @db* [:chats "c1" :todo :tasks]))) (is (= 1 (get-in @db* [:chats "c1" :todo :next-id]))) - (is (= "" (get-in @db* [:chats "c1" :todo :goal]))))))) + (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))))) ;; --- Priority Validation Tests --- @@ -347,12 +367,12 @@ (let [handler (get-in todo/definitions ["todo" :handler])] (testing "rejects invalid priority in add" (let [db* (atom {}) - result (handler {"op" "add" "task" {"content" "T1" "priority" "urgent"}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "priority" "urgent"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)))) (testing "rejects invalid priority in update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"priority" "urgent"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) @@ -360,7 +380,7 @@ (testing "rejects uppercase priority values" (let [db* (atom {}) - result (handler {"op" "add" "task" {"content" "T1" "priority" "HIGH"}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "priority" "HIGH"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) (is (empty? (:tasks (todo/get-todo @db* "c1")))))))) @@ -371,19 +391,19 @@ (let [handler (get-in todo/definitions ["todo" :handler])] (testing "rejects non-array blocked_by" (let [db* (atom {:chats {"c1" {:todo {:tasks []}}}}) - result (handler {"op" "add" "task" {"content" "T1" "blocked_by" "not an array"}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "blocked_by" "not an array"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked_by must be an array"}]} result)))) (testing "rejects blocked_by with non-integer ids" (let [db* (atom {}) - result (handler {"op" "add" "task" {"content" "T1" "blocked_by" ["nope"]}} {:db* db* :chat-id "c1"})] + result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "blocked_by" ["nope"]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked_by must contain integer task IDs"}]} result)) (is (empty? (:tasks (todo/get-todo @db* "c1")))))) (testing "rejects self-reference in update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [1]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"cannot block itself"}]} result)))))) @@ -394,8 +414,8 @@ (let [handler (get-in todo/definitions ["todo" :handler])] (testing "detects 2-node cycle via update" (let [db* (atom {:chats {"c1" {:todo {:next-id 3 - :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}} - {:id 2 :content "T2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) + :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Dependency cycle detected: Task 1 -> Task 2 -> Task 1"}]} @@ -403,8 +423,8 @@ (testing "detects cycle involving existing + new tasks" (let [db* (atom {:chats {"c1" {:todo {:next-id 2 - :tasks [{:id 1 :content "T1" :status :pending :priority :medium :blocked-by #{}}]}}}}) - _ (handler {"op" "add" "task" {"content" "T2" "blocked_by" [1]}} {:db* db* :chat-id "c1"}) + :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + _ (handler {"op" "add" "task" {"subject" "T2" "description" "D2" "blocked_by" [1]}} {:db* db* :chat-id "c1"}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Dependency cycle detected"}]} result)))) @@ -412,10 +432,9 @@ (testing "detects cycle in disconnected component" (let [db* (atom {}) result (handler {"op" "plan" - "goal" "Test" - "tasks" [{"content" "Task 1"} - {"content" "Task 2" "blocked_by" [3]} - {"content" "Task 3" "blocked_by" [2]}]} + "tasks" [{"subject" "Task 1" "description" "D1"} + {"subject" "Task 2" "description" "D2" "blocked_by" [3]} + {"subject" "Task 3" "description" "D3" "blocked_by" [2]}]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Dependency cycle detected"}]} result)))))) @@ -427,9 +446,8 @@ (testing "allows forward references in batch add" (let [db* (atom {})] (handler {"op" "plan" - "goal" "Test" - "tasks" [{"content" "Task 1" "blocked_by" [2]} - {"content" "Task 2"}]} + "tasks" [{"subject" "Task 1" "description" "D1" "blocked_by" [2]} + {"subject" "Task 2" "description" "D2"}]} {:db* db* :chat-id "c1"}) (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) (is (= #{2} (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) @@ -437,17 +455,15 @@ (testing "allows reference to earlier task in batch" (let [db* (atom {})] (handler {"op" "plan" - "goal" "Test" - "tasks" [{"content" "Task 1"} - {"content" "Task 2" "blocked_by" [1]}]} + "tasks" [{"subject" "Task 1" "description" "D1"} + {"subject" "Task 2" "description" "D2" "blocked_by" [1]}]} {:db* db* :chat-id "c1"}) (is (= #{1} (get-in @db* [:chats "c1" :todo :tasks 1 :blocked-by]))))) (testing "rejects reference to non-existent task" (let [db* (atom {}) result (handler {"op" "plan" - "goal" "Test" - "tasks" [{"content" "Task 1" "blocked_by" [99]}]} + "tasks" [{"subject" "Task 1" "description" "D1" "blocked_by" [99]}]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid blocked_by references"}]} result)))))) @@ -456,7 +472,6 @@ (testing "todo details are propagated to tool call details for clients" (let [result {:error false :details {:type :todo - :goal "Goal" :tasks [] :summary {:done 0 :in-progress 0 :pending 0 :total 0}} :contents [{:type :text :text "TODO created"}]} From 677c5d60f3eb1d4d5397f321961a71c6046101db Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Wed, 4 Mar 2026 16:49:10 +0100 Subject: [PATCH 6/8] Simplify operation confirmation output to status and subject only --- src/eca/features/tools/todo.clj | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/eca/features/tools/todo.clj b/src/eca/features/tools/todo.clj index b12273883..228e61d84 100644 --- a/src/eca/features/tools/todo.clj +++ b/src/eca/features/tools/todo.clj @@ -213,11 +213,8 @@ true (conj (str " description: " description)) (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) -(defn ^:private read-task-line-short [{:keys [id subject status priority blocked-by]}] - (let [base (format "- #%d [%s] [%s] %s" id (name status) (name priority) subject)] - (str/join "\n" - (cond-> [base] - (seq blocked-by) (conj (str " blocked_by: " (str/join ", " (sort blocked-by)))))))) +(defn ^:private read-task-line-short [{:keys [id subject status]}] + (format "- #%d [%s] %s" id (name status) subject)) (defn ^:private read-text [state] (let [tasks (:tasks state)] @@ -252,8 +249,8 @@ (defn ^:private success [state text] (assoc (tools.util/single-text-content text) :details (todo-details state))) -(defn ^:private format-tasks-list [tasks & [full?]] - (str/join "\n" (map (if full? read-task-line-full read-task-line-short) tasks))) +(defn ^:private format-tasks-list [tasks] + (str/join "\n" (map read-task-line-short tasks))) (defmethod tools.util/tool-call-details-after-invocation :todo [_name _arguments before-details result _ctx] @@ -401,7 +398,7 @@ (success state (format "Started %d task(s):\n%s" (count started) - (format-tasks-list started true)))))))) + (format-tasks-list started)))))))) (defn ^:private clean-summary-if-no-in-progress [state] (if (empty? (filter #(= :in-progress (:status %)) (:tasks state))) From edca54ba76f234b5de9eb0c900dea29ceaddeb6b Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 4 Mar 2026 13:44:36 -0300 Subject: [PATCH 7/8] todo -> task --- CHANGELOG.md | 2 +- resources/prompts/code_agent.md | 10 +- resources/prompts/tools/{todo.md => task.md} | 10 +- src/eca/config.clj | 6 +- src/eca/db.clj | 2 +- src/eca/features/tools.clj | 8 +- src/eca/features/tools/{todo.clj => task.clj} | 58 ++--- .../tools/{todo_test.clj => task_test.clj} | 220 +++++++++--------- test/eca/features/tools_test.clj | 4 +- 9 files changed, 160 insertions(+), 160 deletions(-) rename resources/prompts/tools/{todo.md => task.md} (88%) rename src/eca/features/tools/{todo.clj => task.clj} (95%) rename test/eca/features/tools/{todo_test.clj => task_test.clj} (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a02a5bd..bc9d0e6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add Todo tool #246 +- Add Task tool #246 ## 0.109.5 diff --git a/resources/prompts/code_agent.md b/resources/prompts/code_agent.md index 66297f293..3e84fcab6 100644 --- a/resources/prompts/code_agent.md +++ b/resources/prompts/code_agent.md @@ -28,18 +28,18 @@ You have tools at your disposal to solve the coding task. Follow these rules reg 3. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. 4. You have the capability to call multiple tools in a single response, batch your tool calls together for optimal performance. -{% if toolEnabled_eca__todo %} -## TODO Tool +{% if toolEnabled_eca__task %} +## Task Tool -You have access to a `eca__todo` tool for tracking multi-step work within this chat. +You have access to a `eca__task` tool for tracking multi-step work within this chat. ### Workflow: -1. Use `plan` to create TODO with initial tasks +1. Use `plan` to create task list with initial tasks 2. Use `start` before working on a task (marks it as in_progress and requires an `active_summary`) 3. Use `complete` only for tasks that are actually finished; for each targeted task, verify its acceptance criteria first. 4. Use `add` if you discover additional work 5. When a plan is fully completed and no further work is needed, always use the `clear` operation to clean up the workspace. -6. When helpful, delegate focused work to subagents. You MAY start multiple independent tasks in parallel (keep the number of in_progress tasks small), then update/complete the TODO based on subagent outputs. Prefer that only the main agent updates the TODO; subagents should focus on producing outputs. +6. When helpful, delegate focused work to subagents. You MAY start multiple independent tasks in parallel (keep the number of in_progress tasks small), then update/complete the task list based on subagent outputs. Prefer that only the main agent updates the task list; subagents should focus on producing outputs. ### Task tracking guidance: - Make sure to add acceptance criteria inside your task `description`. diff --git a/resources/prompts/tools/todo.md b/resources/prompts/tools/task.md similarity index 88% rename from resources/prompts/tools/todo.md rename to resources/prompts/tools/task.md index 0cd8a6bd4..fe6f26908 100644 --- a/resources/prompts/tools/todo.md +++ b/resources/prompts/tools/task.md @@ -12,17 +12,17 @@ When NOT to Use: - Quick fixes where tracking adds no organizational value Operations: -- read: View current TODO state -- plan: Create/replace TODO with initial tasks (required: tasks) -- add: Append task(s) to existing TODO +- read: View current task list state +- plan: Create/replace task list with initial tasks (required: tasks) +- add: Append task(s) to existing task list - update: Modify a single task metadata by `id` (subject, description, priority, blocked_by) — cannot change status - start: Begin work on tasks by `ids` (sets to in_progress; rejects blocked or done tasks). Requires `active_summary` to summarize what you are about to do. - complete: Mark tasks by `ids` as done (verify acceptance criteria in description first) - delete: Remove tasks by `ids` -- clear: Reset entire TODO (removes all tasks) +- clear: Reset entire task list (removes all tasks) Workflow: -1. Use 'plan' to create TODO with initial tasks +1. Use 'plan' to create task list with initial tasks 2. Use 'start' before working on a task — marks it as in_progress and requires an `active_summary` 3. Work sequentially by default. Batch 'start' or 'complete' operations (using multiple 'ids') ONLY for independent tasks being executed simultaneously (e.g., via subagents). 4. Use 'complete' only for tasks that are actually finished; verify acceptance criteria first — the response tells you which tasks got unblocked diff --git a/src/eca/config.clj b/src/eca/config.clj index 32fd349de..1ea8da3e5 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -112,7 +112,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} - "eca__todo" {} + "eca__task" {} "eca__spawn_agent" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} @@ -134,7 +134,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} - "eca__todo" {}} + "eca__task" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "general" {:mode "subagent" @@ -162,7 +162,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} - "eca__todo" {} + "eca__task" {} "eca__spawn_agent" {}} :ask {} :deny {}} diff --git a/src/eca/db.clj b/src/eca/db.clj index 8096dc955..963567484 100644 --- a/src/eca/db.clj +++ b/src/eca/db.clj @@ -60,7 +60,7 @@ :messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason") :content (or :string [::any-map]) ;; string for simple text, map/vector for structured content :content-id :string}] - :todo {:next-id :number + :task {:next-id :number :active-summary (or :string nil) :tasks [{:id :number :subject :string diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index c8ba7e746..71478c543 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -13,7 +13,7 @@ [eca.features.tools.mcp.clojure-mcp] [eca.features.tools.shell :as f.tools.shell] [eca.features.tools.skill :as f.tools.skill] - [eca.features.tools.todo :as f.tools.todo] + [eca.features.tools.task :as f.tools.task] [eca.features.tools.util :as tools.util] [eca.logger :as logger] [eca.messenger :as messenger] @@ -149,7 +149,7 @@ f.tools.editor/definitions f.tools.chat/definitions f.tools.skill/definitions - f.tools.todo/definitions + f.tools.task/definitions (f.tools.agent/definitions config) (f.tools.custom/definitions config)))) @@ -160,9 +160,9 @@ "Filter tools for subagent execution. - Excludes spawn_agent to prevent nesting. - - Excludes todo because TODO state is currently chat-local; it should be managed by the parent agent." + - Excludes task because task list state is currently chat-local; it should be managed by the parent agent." [tools] - (filterv #(not (contains? #{"spawn_agent" "todo"} (:name %))) tools)) + (filterv #(not (contains? #{"spawn_agent" "task"} (:name %))) tools)) (defn all-tools "Returns all available tools, including both native ECA tools diff --git a/src/eca/features/tools/todo.clj b/src/eca/features/tools/task.clj similarity index 95% rename from src/eca/features/tools/todo.clj rename to src/eca/features/tools/task.clj index 228e61d84..4eae27156 100644 --- a/src/eca/features/tools/todo.clj +++ b/src/eca/features/tools/task.clj @@ -1,4 +1,4 @@ -(ns eca.features.tools.todo +(ns eca.features.tools.task (:require [clojure.string :as str] [eca.features.tools.util :as tools.util])) @@ -7,7 +7,7 @@ ;; --- Helpers --- -(def ^:private empty-todo {:next-id 1 :active-summary nil :tasks []}) +(def ^:private empty-task {:next-id 1 :active-summary nil :tasks []}) (def ^:private valid-priorities #{:high :medium :low}) (defn ^:private error [msg] @@ -32,23 +32,23 @@ ;; --- State management --- -(defn get-todo - "Get TODO state for the chat." +(defn get-task + "Get task list state for the chat." [db chat-id] - (get-in db [:chats chat-id :todo] empty-todo)) + (get-in db [:chats chat-id :task] empty-task)) -(defn ^:private mutate-todo! - "Atomically update TODO for a chat via compare-and-set loop. +(defn ^:private mutate-task! + "Atomically update task list for a chat via compare-and-set loop. `mutate-fn` receives current state, returns {:state ...} or {:error true ...}. Extra keys in the result map are preserved." [db* chat-id mutate-fn] (loop [] (let [db @db* - state (get-todo db chat-id) + state (get-task db chat-id) result (mutate-fn state)] (if (:error result) result - (let [new-db (assoc-in db [:chats chat-id :todo] (:state result))] + (let [new-db (assoc-in db [:chats chat-id :task] (:state result))] (if (compare-and-set! db* db new-db) result (recur))))))) @@ -226,13 +226,13 @@ (str/join "\n" (map read-task-line-full tasks)) "(none)")))) -(defn ^:private todo-details +(defn ^:private task-details "Structured data for client rendering." [state] (let [{:keys [tasks active-summary]} state tasks-by-id (task-index tasks) {:keys [done in-progress pending]} (status-counts tasks)] - (cond-> {:type :todo + (cond-> {:type :task :in-progress-task-ids (mapv :id (filter #(= :in-progress (:status %)) tasks)) :tasks (mapv (fn [{:keys [id subject description status priority blocked-by]}] (cond-> {:id id @@ -247,40 +247,40 @@ active-summary (assoc :active-summary active-summary)))) (defn ^:private success [state text] - (assoc (tools.util/single-text-content text) :details (todo-details state))) + (assoc (tools.util/single-text-content text) :details (task-details state))) (defn ^:private format-tasks-list [tasks] (str/join "\n" (map read-task-line-short tasks))) -(defmethod tools.util/tool-call-details-after-invocation :todo +(defmethod tools.util/tool-call-details-after-invocation :task [_name _arguments before-details result _ctx] (or (:details result) before-details)) ;; --- Operations --- (defn ^:private op-read [_arguments {:keys [db chat-id]}] - (let [state (get-todo db chat-id)] + (let [state (get-task db chat-id)] (success state (read-text state)))) (defn ^:private op-plan [{:strs [tasks]} {:keys [db* chat-id]}] (or (when-not (and (sequential? tasks) (seq tasks)) (error "plan requires 'tasks' (non-empty array)")) - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [_state] - (let [built (build-tasks empty-todo tasks)] + (let [built (build-tasks empty-task tasks)] (if (:error built) built (or (detect-cycle (:tasks built)) - {:state (assoc empty-todo + {:state (assoc empty-task :tasks (:tasks built) :next-id (inc (count (:tasks built))))})))))] (if (:error result) result (success (:state result) - (str "TODO created with " (count (get-in result [:state :tasks])) " tasks")))))) + (str "Task list created with " (count (get-in result [:state :tasks])) " tasks")))))) (defn ^:private op-add [{:strs [tasks task] :as _arguments} {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [state] (cond tasks @@ -317,7 +317,7 @@ (def ^:private updatable-keys #{"subject" "description" "priority" "blocked_by"}) (defn ^:private op-update [{:strs [id task]} {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [state] (let [[id err] (resolve-id state id)] (or err @@ -372,7 +372,7 @@ (defn ^:private op-start [{:strs [ids active_summary]} {:keys [db* chat-id]}] (or (require-nonblank "active_summary" active_summary) - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [state] (let [tasks-by-id (task-index (:tasks state)) [ids err] (resolve-ids state ids)] @@ -406,7 +406,7 @@ state)) (defn ^:private op-complete [{:strs [ids]} {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [state] (let [tasks-by-id (task-index (:tasks state)) [ids err] (resolve-ids state ids)] @@ -444,7 +444,7 @@ (format "\nUnblocked: %s" (str/join ", " unblocked))))))))) (defn ^:private op-delete [{:strs [ids]} {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id + (let [result (mutate-task! db* chat-id (fn [state] (let [[ids err] (resolve-ids state ids)] (or err @@ -465,10 +465,10 @@ (format-tasks-list (:deleted result))))))) (defn ^:private op-clear [_arguments {:keys [db* chat-id]}] - (let [result (mutate-todo! db* chat-id (fn [_] {:state empty-todo}))] + (let [result (mutate-task! db* chat-id (fn [_] {:state empty-task}))] (if (:error result) result - (success (:state result) "TODO cleared")))) + (success (:state result) "Task list cleared")))) ;; --- Dispatch --- @@ -482,7 +482,7 @@ "delete" op-delete "clear" op-clear}) -(defn ^:private execute-todo [arguments ctx] +(defn ^:private execute-task [arguments ctx] (let [op (get arguments "op") handler (get ops op)] (if handler @@ -490,8 +490,8 @@ (error (str "Unknown operation: " op))))) (def definitions - {"todo" - {:description (tools.util/read-tool-description "todo") + {"task" + {:description (tools.util/read-tool-description "task") :parameters {:type "object" :properties {:op {:type "string" :enum ["read" "plan" "add" "update" "start" "complete" "delete" "clear"] @@ -520,4 +520,4 @@ :blocked_by {:type "array" :items {:type "integer"}}} :required ["subject" "description"]}}} :required ["op"]} - :handler execute-todo}}) + :handler execute-task}}) diff --git a/test/eca/features/tools/todo_test.clj b/test/eca/features/tools/task_test.clj similarity index 76% rename from test/eca/features/tools/todo_test.clj rename to test/eca/features/tools/task_test.clj index ee68dd7ab..1f2fcced5 100644 --- a/test/eca/features/tools/todo_test.clj +++ b/test/eca/features/tools/task_test.clj @@ -1,52 +1,52 @@ -(ns eca.features.tools.todo-test +(ns eca.features.tools.task-test (:require [clojure.test :refer [deftest is testing]] [matcher-combinators.test :refer [match?]] [eca.features.tools :as f.tools] - [eca.features.tools.todo :as todo])) + [eca.features.tools.task :as task])) (set! *warn-on-reflection* true) ;; --- Chat Isolation Tests --- (deftest chat-isolation-test - (testing "each chat has its own TODO" + (testing "each chat has its own task list" (let [db* (atom {}) - handler (get-in todo/definitions ["todo" :handler])] + handler (get-in task/definitions ["task" :handler])] (handler {"op" "add" "task" {"subject" "Chat 1 Task" "description" "Desc"}} {:db* db* :chat-id "chat-1"}) (handler {"op" "add" "task" {"subject" "Chat 2 Task" "description" "Desc"}} {:db* db* :chat-id "chat-2"}) - (is (= ["Chat 1 Task"] (map :subject (:tasks (todo/get-todo @db* "chat-1"))))) - (is (= ["Chat 2 Task"] (map :subject (:tasks (todo/get-todo @db* "chat-2"))))))) + (is (= ["Chat 1 Task"] (map :subject (:tasks (task/get-task @db* "chat-1"))))) + (is (= ["Chat 2 Task"] (map :subject (:tasks (task/get-task @db* "chat-2"))))))) - (testing "empty chat returns empty TODO" + (testing "empty chat returns empty task list" (is (= {:next-id 1 :active-summary nil :tasks []} - (todo/get-todo {} "nonexistent-chat"))))) + (task/get-task {} "nonexistent-chat"))))) ;; --- State Access Tests --- -(deftest get-todo-test - (testing "returns empty todo for missing chat state" +(deftest get-task-test + (testing "returns empty task list for missing chat state" (is (= {:next-id 1 :active-summary nil :tasks []} - (todo/get-todo {} "missing-chat")))) + (task/get-task {} "missing-chat")))) - (testing "returns stored todo state as-is" + (testing "returns stored task list state as-is" (let [state {:next-id 2 :active-summary "Doing stuff" :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]} - db {:chats {"c1" {:todo state}}}] - (is (= state (todo/get-todo db "c1")))))) + db {:chats {"c1" {:task state}}}] + (is (= state (task/get-task db "c1")))))) ;; --- Read Operation Tests --- (deftest op-read-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "reads empty state" (let [result (handler {"op" "read"} {:db {} :chat-id "c1"})] (is (not (:error result))) - (is (match? {:details {:type :todo :tasks []}} result)))) + (is (match? {:details {:type :task :tasks []}} result)))) (testing "includes structured details" - (let [db {:chats {"c1" {:todo {:active-summary "My active task" + (let [db {:chats {"c1" {:task {:active-summary "My active task" :tasks [{:id 1 :subject "Task 1" :description "Desc 1" @@ -54,7 +54,7 @@ :priority :high :blocked-by #{}}]}}}} result (handler {"op" "read"} {:db db :chat-id "c1"})] - (is (match? {:details {:type :todo + (is (match? {:details {:type :task :active-summary "My active task" :in-progress-task-ids [1] :tasks [{:id 1 @@ -70,7 +70,7 @@ result)))) (testing "read returns full task list text for llm" - (let [db {:chats {"c1" {:todo {:active-summary "My active task" + (let [db {:chats {"c1" {:task {:active-summary "My active task" :tasks [{:id 1 :subject "Task 1" :description "Desc 1" @@ -96,33 +96,33 @@ ;; --- Plan Operation Tests --- (deftest op-plan-test - (let [handler (get-in todo/definitions ["todo" :handler])] - (testing "creates TODO with tasks" + (let [handler (get-in task/definitions ["task" :handler])] + (testing "creates task list with tasks" (let [db* (atom {}) result (handler {"op" "plan" "tasks" [{"subject" "Task 1" "description" "Desc 1"} {"subject" "Task 2" "description" "Desc 2" "blocked_by" [1]}]} {:db* db* :chat-id "c1"})] (is (not (:error result))) - (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= ["Task 1" "Task 2"] (map :subject (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :medium (get-in @db* [:chats "c1" :todo :tasks 0 :priority]))))) + (is (= 2 (count (get-in @db* [:chats "c1" :task :tasks])))) + (is (= ["Task 1" "Task 2"] (map :subject (get-in @db* [:chats "c1" :task :tasks])))) + (is (= :pending (get-in @db* [:chats "c1" :task :tasks 0 :status]))) + (is (= :medium (get-in @db* [:chats "c1" :task :tasks 0 :priority]))))) - (testing "replaces existing TODO completely" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "Old summary" + (testing "replaces existing task list completely" + (let [db* (atom {:chats {"c1" {:task {:active-summary "Old summary" :tasks [{:id 1 :subject "Old Task" :description "Old D" :status :in-progress} {:id 2 :subject "Old Task 2" :description "Old D2" :status :pending}] :next-id 3}}}})] (handler {"op" "plan" "tasks" [{"subject" "New Task" "description" "New D"}]} {:db* db* :chat-id "c1"}) - (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))) + (is (nil? (get-in @db* [:chats "c1" :task :active-summary]))) (is (not-any? #(= "Old Task" (:subject %)) - (get-in @db* [:chats "c1" :todo :tasks]))) - (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= "New Task" (get-in @db* [:chats "c1" :todo :tasks 0 :subject]))) - (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) + (get-in @db* [:chats "c1" :task :tasks]))) + (is (= 1 (count (get-in @db* [:chats "c1" :task :tasks])))) + (is (= "New Task" (get-in @db* [:chats "c1" :task :tasks 0 :subject]))) + (is (= 2 (get-in @db* [:chats "c1" :task :next-id]))))) (testing "requires tasks" (let [db* (atom {}) @@ -147,18 +147,18 @@ ;; --- Add Operation Tests --- (deftest op-add-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "adds single task" (let [db* (atom {})] (handler {"op" "add" "task" {"subject" "Task 1" "description" "Desc 1"}} {:db* db* :chat-id "c1"}) - (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= "Task 1" (get-in @db* [:chats "c1" :todo :tasks 0 :subject]))) - (is (= 2 (get-in @db* [:chats "c1" :todo :next-id]))))) + (is (= 1 (count (get-in @db* [:chats "c1" :task :tasks])))) + (is (= "Task 1" (get-in @db* [:chats "c1" :task :tasks 0 :subject]))) + (is (= 2 (get-in @db* [:chats "c1" :task :next-id]))))) (testing "adds batch of tasks" (let [db* (atom {})] (handler {"op" "add" "tasks" [{"subject" "T1" "description" "D1"} {"subject" "T2" "description" "D2"}]} {:db* db* :chat-id "c1"}) - (let [tasks (get-in @db* [:chats "c1" :todo :tasks])] + (let [tasks (get-in @db* [:chats "c1" :task :tasks])] (is (= 2 (count tasks))) (is (= [1 2] (map :id tasks)))))) @@ -182,29 +182,29 @@ ;; --- Update Operation Tests --- (deftest op-update-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "updates task metadata" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "Old" :description "Old D" :status :pending :priority :medium :blocked-by #{}}]}}}})] + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "Old" :description "Old D" :status :pending :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "update" "id" 1 "task" {"subject" "New" "priority" "high"}} {:db* db* :chat-id "c1"}) - (let [task (first (get-in @db* [:chats "c1" :todo :tasks]))] + (let [task (first (get-in @db* [:chats "c1" :task :tasks]))] (is (= "New" (:subject task))) (is (= :high (:priority task)))))) (testing "rejects status changes via update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"status" "done"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot set status via update"}]} result)) - (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 0 :status]))))) + (is (= :pending (get-in @db* [:chats "c1" :task :tasks 0 :status]))))) (testing "rejects empty updates" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) result (handler {"op" "update" "id" 1 "task" {}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"No updatable fields"}]} result)))) (testing "returns an error response for unknown id" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending}]}}}}) result (handler {"op" "update" "id" 999 "task" {"subject" "New"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"not found"}]} result)))))) @@ -212,57 +212,57 @@ ;; --- Start Operation Tests --- (deftest op-start-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "starts pending tasks by ids" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "start" "ids" [1 2] "active_summary" "Doing T1 and T2"} {:db* db* :chat-id "c1"}) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) - (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (= :in-progress (get-in @db* [:chats "c1" :task :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :task :tasks 1 :status]))) + (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "does not demote other in-progress tasks" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "Doing T1" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:active-summary "Doing T1" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "start" "ids" [2] "active_summary" "Doing T1 and T2"} {:db* db* :chat-id "c1"}) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) - (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (= :in-progress (get-in @db* [:chats "c1" :task :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :task :tasks 1 :status]))) + (is (= "Doing T1 and T2" (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "requires ids for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "requires active_summary for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "ids" [1]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"active_summary must be a non-blank string"}]} result)))) (testing "rejects blank active_summary for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "ids" [1] "active_summary" " "} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"active_summary must be a non-blank string"}]} result)))) (testing "rejects starting a done task" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :done :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :done :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "ids" [1] "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"already done"}]} result)))) (testing "rejects starting a blocked task" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "start" "ids" [2] "active_summary" "Doing T2"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked by 1"}]} result)) - (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + (is (= :pending (get-in @db* [:chats "c1" :task :tasks 1 :status]))))) (testing "rejects duplicate ids for start" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "start" "ids" [1 1] "active_summary" "Doing T1"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -270,45 +270,45 @@ ;; --- Complete Operation Tests --- (deftest op-complete-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "completes tasks by ids" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "complete" "ids" [1 2]} {:db* db* :chat-id "c1"}) - (is (= :done (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :done (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) - (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (= :done (get-in @db* [:chats "c1" :task :tasks 0 :status]))) + (is (= :done (get-in @db* [:chats "c1" :task :tasks 1 :status]))) + (is (nil? (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "leaves active summary if tasks are in-progress" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "complete" "ids" [1]} {:db* db* :chat-id "c1"}) - (is (= :done (get-in @db* [:chats "c1" :todo :tasks 0 :status]))) - (is (= :in-progress (get-in @db* [:chats "c1" :todo :tasks 1 :status]))) - (is (= "My task" (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (= :done (get-in @db* [:chats "c1" :task :tasks 0 :status]))) + (is (= :in-progress (get-in @db* [:chats "c1" :task :tasks 1 :status]))) + (is (= "My task" (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "shows unblocked tasks" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "complete" "ids" [1]} {:db* db* :chat-id "c1"})] (is (match? {:contents [{:type :text :text #"Unblocked"}]} result)))) (testing "rejects blocked tasks for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "complete" "ids" [2]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Cannot complete task 2: blocked by 1"}]} result)) - (is (= :pending (get-in @db* [:chats "c1" :todo :tasks 1 :status]))))) + (is (= :pending (get-in @db* [:chats "c1" :task :tasks 1 :status]))))) (testing "requires ids for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "complete"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "rejects duplicate ids for complete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "complete" "ids" [1 1]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -316,36 +316,36 @@ ;; --- Delete Operation Tests --- (deftest op-delete-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "deletes tasks by ids and clears blocked_by references" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}} {:id 3 :subject "T3" :description "D3" :status :pending :priority :medium :blocked-by #{1 2}}]}}}})] (handler {"op" "delete" "ids" [1 2]} {:db* db* :chat-id "c1"}) - (is (= 1 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= 3 (get-in @db* [:chats "c1" :todo :tasks 0 :id]))) - (is (empty? (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) + (is (= 1 (count (get-in @db* [:chats "c1" :task :tasks])))) + (is (= 3 (get-in @db* [:chats "c1" :task :tasks 0 :id]))) + (is (empty? (get-in @db* [:chats "c1" :task :tasks 0 :blocked-by]))))) (testing "deletes tasks clears active-summary when no in-progress remaining" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}})] (handler {"op" "delete" "ids" [1]} {:db* db* :chat-id "c1"}) - (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (nil? (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "delete leaves active-summary when in-progress task remains" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} + (let [db* (atom {:chats {"c1" {:task {:active-summary "My task" :tasks [{:id 1 :subject "T1" :description "D1" :status :in-progress :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :in-progress :priority :medium :blocked-by #{}}]}}}})] (handler {"op" "delete" "ids" [1]} {:db* db* :chat-id "c1"}) - (is (= "My task" (get-in @db* [:chats "c1" :todo :active-summary]))))) + (is (= "My task" (get-in @db* [:chats "c1" :task :active-summary]))))) (testing "requires ids for delete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "delete"} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"non-empty array"}]} result)))) (testing "rejects duplicate ids for delete" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "delete" "ids" [1 1]} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Duplicate IDs"}]} result)))))) @@ -353,18 +353,18 @@ ;; --- Clear Operation Tests --- (deftest op-clear-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "resets to empty state" - (let [db* (atom {:chats {"c1" {:todo {:active-summary "Summary" :tasks [{:id 1}] :next-id 2}}}})] + (let [db* (atom {:chats {"c1" {:task {:active-summary "Summary" :tasks [{:id 1}] :next-id 2}}}})] (handler {"op" "clear"} {:db* db* :chat-id "c1"}) - (is (empty? (get-in @db* [:chats "c1" :todo :tasks]))) - (is (= 1 (get-in @db* [:chats "c1" :todo :next-id]))) - (is (nil? (get-in @db* [:chats "c1" :todo :active-summary]))))))) + (is (empty? (get-in @db* [:chats "c1" :task :tasks]))) + (is (= 1 (get-in @db* [:chats "c1" :task :next-id]))) + (is (nil? (get-in @db* [:chats "c1" :task :active-summary]))))))) ;; --- Priority Validation Tests --- (deftest priority-validation-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "rejects invalid priority in add" (let [db* (atom {}) result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "priority" "urgent"}} {:db* db* :chat-id "c1"})] @@ -372,25 +372,25 @@ (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)))) (testing "rejects invalid priority in update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"priority" "urgent"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) - (is (= :medium (get-in @db* [:chats "c1" :todo :tasks 0 :priority]))))) + (is (= :medium (get-in @db* [:chats "c1" :task :tasks 0 :priority]))))) (testing "rejects uppercase priority values" (let [db* (atom {}) result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "priority" "HIGH"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)) - (is (empty? (:tasks (todo/get-todo @db* "c1")))))))) + (is (empty? (:tasks (task/get-task @db* "c1")))))))) ;; --- Blocked By Validation Tests --- (deftest blocked-by-validation-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "rejects non-array blocked_by" - (let [db* (atom {:chats {"c1" {:todo {:tasks []}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks []}}}}) result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "blocked_by" "not an array"}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked_by must be an array"}]} result)))) @@ -400,10 +400,10 @@ result (handler {"op" "add" "task" {"subject" "T1" "description" "D1" "blocked_by" ["nope"]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"blocked_by must contain integer task IDs"}]} result)) - (is (empty? (:tasks (todo/get-todo @db* "c1")))))) + (is (empty? (:tasks (task/get-task @db* "c1")))))) (testing "rejects self-reference in update" - (let [db* (atom {:chats {"c1" {:todo {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) + (let [db* (atom {:chats {"c1" {:task {:tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [1]}} {:db* db* :chat-id "c1"})] (is (:error result)) (is (match? {:contents [{:type :text :text #"cannot block itself"}]} result)))))) @@ -411,9 +411,9 @@ ;; --- Dependency Cycle Detection Tests --- (deftest dependency-cycle-detection-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "detects 2-node cycle via update" - (let [db* (atom {:chats {"c1" {:todo {:next-id 3 + (let [db* (atom {:chats {"c1" {:task {:next-id 3 :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}} {:id 2 :subject "T2" :description "D2" :status :pending :priority :medium :blocked-by #{1}}]}}}}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] @@ -422,7 +422,7 @@ result)))) (testing "detects cycle involving existing + new tasks" - (let [db* (atom {:chats {"c1" {:todo {:next-id 2 + (let [db* (atom {:chats {"c1" {:task {:next-id 2 :tasks [{:id 1 :subject "T1" :description "D1" :status :pending :priority :medium :blocked-by #{}}]}}}}) _ (handler {"op" "add" "task" {"subject" "T2" "description" "D2" "blocked_by" [1]}} {:db* db* :chat-id "c1"}) result (handler {"op" "update" "id" 1 "task" {"blocked_by" [2]}} {:db* db* :chat-id "c1"})] @@ -442,15 +442,15 @@ ;; --- Forward Reference in Batch Tests --- (deftest forward-reference-test - (let [handler (get-in todo/definitions ["todo" :handler])] + (let [handler (get-in task/definitions ["task" :handler])] (testing "allows forward references in batch add" (let [db* (atom {})] (handler {"op" "plan" "tasks" [{"subject" "Task 1" "description" "D1" "blocked_by" [2]} {"subject" "Task 2" "description" "D2"}]} {:db* db* :chat-id "c1"}) - (is (= 2 (count (get-in @db* [:chats "c1" :todo :tasks])))) - (is (= #{2} (get-in @db* [:chats "c1" :todo :tasks 0 :blocked-by]))))) + (is (= 2 (count (get-in @db* [:chats "c1" :task :tasks])))) + (is (= #{2} (get-in @db* [:chats "c1" :task :tasks 0 :blocked-by]))))) (testing "allows reference to earlier task in batch" (let [db* (atom {})] @@ -458,7 +458,7 @@ "tasks" [{"subject" "Task 1" "description" "D1"} {"subject" "Task 2" "description" "D2" "blocked_by" [1]}]} {:db* db* :chat-id "c1"}) - (is (= #{1} (get-in @db* [:chats "c1" :todo :tasks 1 :blocked-by]))))) + (is (= #{1} (get-in @db* [:chats "c1" :task :tasks 1 :blocked-by]))))) (testing "rejects reference to non-existent task" (let [db* (atom {}) @@ -468,15 +468,15 @@ (is (:error result)) (is (match? {:contents [{:type :text :text #"Invalid blocked_by references"}]} result)))))) -(deftest todo-tool-call-details-test - (testing "todo details are propagated to tool call details for clients" +(deftest task-tool-call-details-test + (testing "task details are propagated to tool call details for clients" (let [result {:error false - :details {:type :todo + :details {:type :task :tasks [] :summary {:done 0 :in-progress 0 :pending 0 :total 0}} - :contents [{:type :text :text "TODO created"}]} + :contents [{:type :text :text "Task list created"}]} details (f.tools/tool-call-details-after-invocation - :todo + :task {"op" "plan"} nil result diff --git a/test/eca/features/tools_test.clj b/test/eca/features/tools_test.clj index 553b47b2d..699677ead 100644 --- a/test/eca/features/tools_test.clj +++ b/test/eca/features/tools_test.clj @@ -35,12 +35,12 @@ :origin :native}]) (f.tools/all-tools "123" "code" {} {})))) - (testing "Subagent excludes spawn_agent and todo tools" + (testing "Subagent excludes spawn_agent and task tools" (let [db {:chats {"sub-1" {:subagent {:name "explorer"}}}} tools (f.tools/all-tools "sub-1" "code" db {}) tool-names (set (map :name tools))] (is (not (contains? tool-names "spawn_agent"))) - (is (not (contains? tool-names "todo"))))) + (is (not (contains? tool-names "task"))))) (testing "Do not include disabled native tools" (is (match? From ac041749a18df3a65a2937c4f601051a71bfc573 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Thu, 5 Mar 2026 10:19:10 -0300 Subject: [PATCH 8/8] last improvements --- resources/prompts/compact.md | 1 + src/eca/features/chat.clj | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/prompts/compact.md b/resources/prompts/compact.md index 7ba5f31db..448209231 100644 --- a/resources/prompts/compact.md +++ b/resources/prompts/compact.md @@ -8,6 +8,7 @@ For technical or coding sessions, cover the following points: - Key decisions and architectural changes - Unfinished tasks and next steps - Technical details that need to be preserved + - Always save task lists if any. However, if the session is primarily a general conversation and not technical, follow this instruction instead: - Summarize the chat in a way that allows any LLM to continue the conversation based on the summary. diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 65fe5d68b..be2470f85 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -1824,7 +1824,7 @@ (fn [chat] (cond-> chat messages (-> (assoc :messages []) - (dissoc :tool-calls :last-api :usage))))) + (dissoc :tool-calls :last-api :usage :task))))) (db/update-workspaces-cache! @db* metrics))) (defn rollback-chat