diff --git a/CHANGELOG.md b/CHANGELOG.md index 1367ba7b..9f6d3138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Fix rollback when there are subagents messages in chat. +- Add Task tool #246 ## 0.109.6 diff --git a/resources/prompts/code_agent.md b/resources/prompts/code_agent.md index 598fab91..3e84fcab 100644 --- a/resources/prompts/code_agent.md +++ b/resources/prompts/code_agent.md @@ -27,3 +27,21 @@ 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__task %} +## Task Tool + +You have access to a `eca__task` tool for tracking multi-step work within this chat. + +### Workflow: +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 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`. +- Objective verification of acceptance criteria is required for completion. +{% endif %} diff --git a/resources/prompts/compact.md b/resources/prompts/compact.md index 7ba5f31d..44820923 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/resources/prompts/tools/task.md b/resources/prompts/tools/task.md new file mode 100644 index 00000000..fe6f2690 --- /dev/null +++ b/resources/prompts/tools/task.md @@ -0,0 +1,40 @@ +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 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 task list (removes all tasks) + +Workflow: +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 +5. Use 'add' if you discover additional work during execution +6. When a plan is fully completed and no further work is needed, always use the 'clear' operation to clean up the workspace. + +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. +- 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/config.clj b/src/eca/config.clj index 0fe31e21..1ea8da3e 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -112,6 +112,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} + "eca__task" {} "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__task" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "general" {:mode "subagent" @@ -160,6 +162,7 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} + "eca__task" {} "eca__spawn_agent" {}} :ask {} :deny {}} diff --git a/src/eca/db.clj b/src/eca/db.clj index 0bb71fdb..96356748 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}] + :task {:next-id :number + :active-summary (or :string nil) + :tasks [{:id :number + :subject :string + :description :string + :status (or :pending :in-progress :done) + :priority (or :high :medium :low) + :blocked-by #{:number}}]} :tool-calls {"" {:status (or :initial :preparing :check-approval :waiting-approval :execution-approved :executing :rejected :cleanup diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 65fe5d68..be2470f8 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 diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 73aa16b0..71478c54 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.task :as f.tools.task] [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.task/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 task because task list 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" "task"} (:name %))) tools)) (defn all-tools "Returns all available tools, including both native ECA tools diff --git a/src/eca/features/tools/task.clj b/src/eca/features/tools/task.clj new file mode 100644 index 00000000..4eae2715 --- /dev/null +++ b/src/eca/features/tools/task.clj @@ -0,0 +1,523 @@ +(ns eca.features.tools.task + (:require + [clojure.string :as str] + [eca.features.tools.util :as tools.util])) + +(set! *warn-on-reflection* true) + +;; --- Helpers --- + +(def ^:private empty-task {:next-id 1 :active-summary nil :tasks []}) +(def ^:private valid-priorities #{:high :medium :low}) + +(defn ^:private error [msg] + (tools.util/single-text-content msg true)) + +(defn ^:private find-task [state id] + (some #(when (= id (:id %)) %) (:tasks state))) + +(defn ^:private task-index + "Index tasks by ID for O(1) lookups." + [tasks] + (into {} (map (juxt :id identity)) tasks)) + +(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)] + (->> (:blocked-by task) + (remove #(= :done (:status (get tasks-by-id %)))) + sort + seq))) + +;; --- State management --- + +(defn get-task + "Get task list state for the chat." + [db chat-id] + (get-in db [:chats chat-id :task] empty-task)) + +(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-task db chat-id) + result (mutate-fn state)] + (if (:error result) + result + (let [new-db (assoc-in db [:chats chat-id :task] (: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 ^:private 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 ^: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 ^:private 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 ^:private 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 ^:private 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 ^:private 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 ^:private make-task + "Build a task map from string-keyed JSON input. Returns {:task ...} or error response." + [raw-task id allowed-ids] + (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)) + (let [[blocked-by err] (validate-blocked-by blocked_by allowed-ids id)] + (or err + {:task {:id id + :subject subject + :description description + :status :pending + :priority (if (contains? raw-task "priority") + (keyword priority) + :medium) + :blocked-by blocked-by}}))))) + +(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)) + (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 ^: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 ^: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 ^:private summary-line-with-total [tasks] + (format "%s, %d total" (summary-line tasks) (count tasks))) + +(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]}] + (format "- #%d [%s] %s" id (name status) subject)) + +(defn ^:private read-text [state] + (let [tasks (:tasks state)] + (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-full tasks)) + "(none)")))) + +(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 :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 + :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 (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 :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-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-task! db* chat-id + (fn [_state] + (let [built (build-tasks empty-task tasks)] + (if (:error built) + built + (or (detect-cycle (:tasks built)) + {:state (assoc empty-task + :tasks (:tasks built) + :next-id (inc (count (:tasks built))))})))))] + (if (:error result) + result + (success (:state result) + (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-task! 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 #{"subject" "description" "priority" "blocked_by"}) + +(defn ^:private op-update [{:strs [id task]} {:keys [db* chat-id]}] + (let [result (mutate-task! 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 [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)) + (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 "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))] + (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 ^:private set-status + "Set status on tasks by IDs." + [tasks id-set status] + (mapv #(if (contains? id-set (:id %)) (assoc % :status status) %) tasks)) + +(defn ^:private op-start [{:strs [ids active_summary]} {:keys [db* chat-id]}] + (or (require-nonblank "active_summary" active_summary) + (let [result (mutate-task! 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)))))))) + +(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-task! 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 (-> state + (assoc :tasks (set-status (:tasks state) (set ids) :done)) + clean-summary-if-no-in-progress) + :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 ^:private op-delete [{:strs [ids]} {:keys [db* chat-id]}] + (let [result (mutate-task! 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 (-> state + (assoc :tasks remaining) + clean-summary-if-no-in-progress) + :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 ^:private op-clear [_arguments {:keys [db* chat-id]}] + (let [result (mutate-task! db* chat-id (fn [_] {:state empty-task}))] + (if (:error result) + result + (success (:state result) "Task list 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 ^:private execute-task [arguments ctx] + (let [op (get arguments "op") + handler (get ops op)] + (if handler + (handler arguments ctx) + (error (str "Unknown operation: " op))))) + +(def definitions + {"task" + {:description (tools.util/read-tool-description "task") + :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)"} + :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 {: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)"} + :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 {: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"]} + :blocked_by {:type "array" :items {:type "integer"}}} + :required ["subject" "description"]}}} + :required ["op"]} + :handler execute-task}}) diff --git a/test/eca/features/tools/task_test.clj b/test/eca/features/tools/task_test.clj new file mode 100644 index 00000000..1f2fcced --- /dev/null +++ b/test/eca/features/tools/task_test.clj @@ -0,0 +1,484 @@ +(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.task :as task])) + +(set! *warn-on-reflection* true) + +;; --- Chat Isolation Tests --- + +(deftest chat-isolation-test + (testing "each chat has its own task list" + (let [db* (atom {}) + 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 (task/get-task @db* "chat-1"))))) + (is (= ["Chat 2 Task"] (map :subject (:tasks (task/get-task @db* "chat-2"))))))) + + (testing "empty chat returns empty task list" + (is (= {:next-id 1 :active-summary nil :tasks []} + (task/get-task {} "nonexistent-chat"))))) + +;; --- State Access Tests --- + +(deftest get-task-test + (testing "returns empty task list for missing chat state" + (is (= {:next-id 1 :active-summary nil :tasks []} + (task/get-task {} "missing-chat")))) + + (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" {:task state}}}] + (is (= state (task/get-task db "c1")))))) + +;; --- Read Operation Tests --- + +(deftest op-read-test + (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 :task :tasks []}} result)))) + + (testing "includes structured details" + (let [db {:chats {"c1" {:task {:active-summary "My active task" + :tasks [{:id 1 + :subject "Task 1" + :description "Desc 1" + :status :in-progress + :priority :high + :blocked-by #{}}]}}}} + result (handler {"op" "read"} {:db db :chat-id "c1"})] + (is (match? {:details {:type :task + :active-summary "My active task" + :in-progress-task-ids [1] + :tasks [{:id 1 + :subject "Task 1" + :description "Desc 1" + :status "in-progress" + :priority "high" + :is-blocked false}] + :summary {:done 0 + :in-progress 1 + :pending 0 + :total 1}}} + result)))) + + (testing "read returns full task list text for llm" + (let [db {:chats {"c1" {:task {:active-summary "My active task" + :tasks [{:id 1 + :subject "Task 1" + :description "Desc 1" + :status :in-progress + :priority :high + :blocked-by #{}} + {:id 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 #"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 #"description: Desc 1" 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 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" :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 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" :task :active-summary]))) + (is (not-any? #(= "Old Task" (:subject %)) + (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 {}) + 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" "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" + "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)))))) + +;; --- Add Operation Tests --- + +(deftest op-add-test + (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" :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" :task :tasks])] + (is (= 2 (count tasks))) + (is (= [1 2] (map :id tasks)))))) + + (testing "validates required subject" + (let [db* (atom {}) + 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" {"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)))) + + (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 task/definitions ["task" :handler])] + (testing "updates task metadata" + (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" :task :tasks]))] + (is (= "New" (:subject task))) + (is (= :high (:priority task)))))) + + (testing "rejects status changes via update" + (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" :task :tasks 0 :status]))))) + + (testing "rejects empty updates" + (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" {: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)))))) + +;; --- Start Operation Tests --- + +(deftest op-start-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "starts pending tasks by ids" + (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" :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" {: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" :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" {: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" {: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" {: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" {: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" {: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" :task :tasks 1 :status]))))) + + (testing "rejects duplicate ids for start" + (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)))))) + +;; --- Complete Operation Tests --- + +(deftest op-complete-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "completes tasks by ids" + (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" :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" {: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" :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" {: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" {: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" :task :tasks 1 :status]))))) + + (testing "requires ids for complete" + (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" {: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)))))) + +;; --- Delete Operation Tests --- + +(deftest op-delete-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "deletes tasks by ids and clears blocked_by references" + (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" :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" {: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" :task :active-summary]))))) + + (testing "delete leaves active-summary when in-progress task remains" + (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" :task :active-summary]))))) + + (testing "requires ids for delete" + (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" {: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)))))) + +;; --- Clear Operation Tests --- + +(deftest op-clear-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "resets to empty state" + (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" :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 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"})] + (is (:error result)) + (is (match? {:contents [{:type :text :text #"Invalid priority"}]} result)))) + + (testing "rejects invalid priority in update" + (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" :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 (task/get-task @db* "c1")))))))) + +;; --- Blocked By Validation Tests --- + +(deftest blocked-by-validation-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "rejects non-array blocked_by" + (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)))) + + (testing "rejects blocked_by with non-integer ids" + (let [db* (atom {}) + 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 (task/get-task @db* "c1")))))) + + (testing "rejects self-reference in update" + (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)))))) + +;; --- Dependency Cycle Detection Tests --- + +(deftest dependency-cycle-detection-test + (let [handler (get-in task/definitions ["task" :handler])] + (testing "detects 2-node cycle via update" + (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"})] + (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" {: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"})] + (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" + "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)))))) + +;; --- Forward Reference in Batch Tests --- + +(deftest forward-reference-test + (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" :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 {})] + (handler {"op" "plan" + "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" :task :tasks 1 :blocked-by]))))) + + (testing "rejects reference to non-existent task" + (let [db* (atom {}) + result (handler {"op" "plan" + "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)))))) + +(deftest task-tool-call-details-test + (testing "task details are propagated to tool call details for clients" + (let [result {:error false + :details {:type :task + :tasks [] + :summary {:done 0 :in-progress 0 :pending 0 :total 0}} + :contents [{:type :text :text "Task list created"}]} + details (f.tools/tool-call-details-after-invocation + :task + {"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 7c2edfad..699677ea 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 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 "task"))))) + (testing "Do not include disabled native tools" (is (match? (m/embeds [(m/mismatch {:name "directory_tree"})])