Skip to content

Commit 98da790

Browse files
committed
Add /delete-chat command to delete chats with confirmation
Deletes the current chat or a chat-id from the /resume list after a confirm step, reusing the chat/delete path (chatEnd hook, tombstone, chat/deleted notification) so clients close the chat automatically. The current chat's turn is finished before deleting so the status transition can't resurrect it as a ghost record.
1 parent a396719 commit 98da790

6 files changed

Lines changed: 152 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add `/btw` command to ask a side question in a forked chat, keeping the current chat history clean. Runs immediately even while the chat is running (steered /btw is not queued).
6+
- Add `/delete-chat` command to permanently delete the current chat or a chat-id from the `/resume` list, asking confirmation first.
67

78
## 0.155.2
89

docs/features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ The built-in commands are:
135135
- `/fork`: Fork the current chat into a new chat with the same history and settings.
136136
- `/btw`: Ask a side question in a forked chat, keeping the current chat history clean. Ex: `/btw how does X work?`.
137137
- `/resume`: Resume a chat from previous session of this workspace folder.
138+
- `/delete-chat`: Permanently delete the current chat or a chat-id from the `/resume` list, asking confirmation. Ex: `/delete-chat`, `/delete-chat 2`.
138139
- `/costs`: Show costs about current session.
139140
- `/config`: Show ECA config for troubleshooting.
140141
- `/doctor`: Show information about ECA, useful for troubleshooting.

src/eca/features/chat.clj

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@
618618
(filter #({"preRequest" "prePrompt"} (:type (val %))))
619619
(sort-by key)))))
620620

621-
(declare prompt prompt-messages!)
621+
(declare prompt prompt-messages! delete-chat)
622622

623623
(defn ^:private tokenize-args [^String s]
624624
(if (string/blank? s)
@@ -1639,6 +1639,15 @@
16391639
:title title)))))
16401640
(lifecycle/finish-chat-prompt! :idle (assoc chat-ctx :skip-post-request-hooks? true)))
16411641
:new-chat-status (lifecycle/finish-chat-prompt! (:status result) (assoc chat-ctx :skip-post-request-hooks? true))
1642+
:delete-chat (let [{:keys [target-chat-id text]} result
1643+
{:keys [db* messenger config metrics]} chat-ctx]
1644+
(when text
1645+
(lifecycle/send-content! chat-ctx :system {:type :text :text text}))
1646+
;; Finish the turn BEFORE deleting: finish-chat-prompt!'s
1647+
;; status transition on a deleted chat would resurrect it
1648+
;; as a ghost record.
1649+
(lifecycle/finish-chat-prompt! :idle (assoc chat-ctx :skip-post-request-hooks? true))
1650+
(delete-chat {:chat-id target-chat-id} db* messenger config metrics))
16421651
:side-prompt (let [{:keys [target-chat-id target-title target-messages message notice-text]} result
16431652
{:keys [chat-id db*]} chat-ctx]
16441653
(lifecycle/send-content! chat-ctx :system {:type :text :text notice-text})

src/eca/features/commands.clj

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@
228228
:type :native
229229
:description "Resume the specified chat-id. Blank to list chats or 'latest'."
230230
:arguments [{:name "chat-id"}]}
231+
{:name "delete-chat"
232+
:type :native
233+
:description "Permanently delete the current chat or a chat-id from the /resume list, asking confirmation. (Ex: /delete-chat, /delete-chat 2)"
234+
:arguments [{:name "chat-id"}]}
231235
{:name "export"
232236
:type :native
233237
:description "Export the current chat to a file or directory to transfer/import it elsewhere. Ex: /export /tmp/chat.edn or /export ."
@@ -821,6 +825,51 @@
821825
:chats {chat-id {:title (:title chat)
822826
:messages (concat [{:role "system" :content [{:type :text :text (str "Resuming chat: " selected-chat-id)}]}]
823827
(:messages chat))}}})))
828+
"delete-chat" (let [confirm? (= "confirm" (some-> (last args) string/lower-case))
829+
target-arg (first (remove #(= "confirm" (some-> % string/lower-case)) args))
830+
other-chats (into {}
831+
(filter #(and (not= chat-id (first %))
832+
(not (:subagent (second %)))))
833+
(:chats db))
834+
;; Same ordering as /resume so its listed ids work here.
835+
chats-ids (vec (sort-by #(:created-at (get other-chats %)) (keys other-chats)))
836+
target-id (cond
837+
(nil? target-arg) chat-id
838+
(= target-arg chat-id) chat-id
839+
(contains? other-chats target-arg) target-arg
840+
:else (when-let [n (parse-long target-arg)]
841+
(nth chats-ids (dec n) nil)))
842+
target-chat (get-in db [:chats target-id])
843+
current? (= target-id chat-id)
844+
chat-message-fn (fn [text]
845+
{:type :chat-messages
846+
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text text}]}]}}})
847+
describe-fn (fn [chat]
848+
(or (:title chat)
849+
(format "No chat title (%s user messages)"
850+
(or (:user-message-count (db/chat-list-meta chat)) 0))))]
851+
(cond
852+
(nil? target-id)
853+
(chat-message-fn "Chat ID not found. Run `/resume` to list chats.")
854+
855+
(and (not current?)
856+
(db/hydrated? target-chat)
857+
(contains? #{:running :stopping} (:status target-chat)))
858+
(chat-message-fn (format "Chat '%s' has a running prompt, stop it before deleting." (describe-fn target-chat)))
859+
860+
(not confirm?)
861+
(chat-message-fn
862+
(multi-str
863+
(if current?
864+
"This will permanently delete the current chat."
865+
(format "This will permanently delete chat: %s" (describe-fn target-chat)))
866+
(str "Run `/delete-chat " (when target-arg (str target-arg " ")) "confirm` to proceed.")))
867+
868+
:else
869+
{:type :delete-chat
870+
:target-chat-id target-id
871+
:text (when-not current?
872+
(format "Deleted chat: %s" (describe-fn target-chat)))}))
824873
"costs" (let [total-input-tokens (get-in db [:chats chat-id :usage :total-input-tokens] 0)
825874
total-input-cache-creation-tokens (get-in db [:chats chat-id :usage :total-input-cache-creation-tokens] nil)
826875
total-input-cache-read-tokens (get-in db [:chats chat-id :usage :total-input-cache-read-tokens] nil)

test/eca/features/chat_test.clj

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,6 +1563,41 @@
15631563
(is (string/includes? (:text @sent-content) "No response from prompt"))
15641564
(is (= :idle @finished-status))))))
15651565

1566+
(deftest delete-chat-command-test
1567+
(testing "/delete-chat asks confirmation and /delete-chat confirm deletes the chat"
1568+
(h/reset-components!)
1569+
(let [{:keys [chat-id]}
1570+
(prompt!
1571+
{:message "hi"}
1572+
{:all-tools-mock (constantly [])
1573+
:api-mock
1574+
(fn [{:keys [on-first-response-received on-message-received]}]
1575+
(on-first-response-received {:type :text :text "hello"})
1576+
(on-message-received {:type :text :text "hello"})
1577+
(on-message-received {:type :finish}))})
1578+
llm-mock (fn [& _] (throw (ex-info "commands should not call the LLM" {})))]
1579+
(h/reset-messenger!)
1580+
(prompt! {:message "/delete-chat" :chat-id chat-id}
1581+
{:all-tools-mock (constantly [])
1582+
:api-mock llm-mock})
1583+
(is (match?
1584+
{:chat-content-received
1585+
(m/embeds [{:chat-id chat-id
1586+
:role "system"
1587+
:content {:type :text
1588+
:text #(string/includes? % "Run `/delete-chat confirm` to proceed.")}}])}
1589+
(h/messages)))
1590+
(is (some? (get-in (h/db) [:chats chat-id]))
1591+
"chat is kept until deletion is confirmed")
1592+
(h/reset-messenger!)
1593+
(prompt! {:message "/delete-chat confirm" :chat-id chat-id}
1594+
{:all-tools-mock (constantly [])
1595+
:api-mock llm-mock})
1596+
(is (match? {:chat-deleted [{:chat-id chat-id}]}
1597+
(h/messages)))
1598+
(is (nil? (get-in (h/db) [:chats chat-id])))
1599+
(is (contains? (:deleted-chat-ids (h/db)) chat-id)))))
1600+
15661601
(deftest message->decision-test
15671602
(testing "plain prompt message"
15681603
(is (= {:type :prompt-message

test/eca/features/commands_test.clj

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,62 @@
536536
(:config-updated (h/messages))))
537537
(is (= session-defaults (:last-config-notified (h/db)))))))
538538

539+
(deftest delete-chat-command-test
540+
(testing "no confirm asks for confirmation for the current chat"
541+
(h/reset-components!)
542+
(swap! (h/db*) assoc :chats {"chat-b" {:id "chat-b" :messages []}})
543+
(let [result (f.commands/handle-command! "delete-chat" [] (command-context "chat-b"))]
544+
(is (= :chat-messages (:type result)))
545+
(is (string/includes? (get-in result [:chats "chat-b" :messages 0 :content 0 :text])
546+
"Run `/delete-chat confirm` to proceed."))))
547+
548+
(testing "confirm deletes the current chat"
549+
(h/reset-components!)
550+
(swap! (h/db*) assoc :chats {"chat-b" {:id "chat-b" :messages []}})
551+
(is (= {:type :delete-chat :target-chat-id "chat-b" :text nil}
552+
(f.commands/handle-command! "delete-chat" ["confirm"] (command-context "chat-b")))))
553+
554+
(testing "chat-id arg resolves like the /resume listing"
555+
(h/reset-components!)
556+
(swap! (h/db*) assoc :chats {"chat-a" {:id "chat-a" :created-at 1 :title "First" :messages []}
557+
"chat-b" {:id "chat-b" :created-at 2 :messages []}
558+
"chat-c" {:id "chat-c" :created-at 3 :title "Third" :messages []}})
559+
(let [ask (f.commands/handle-command! "delete-chat" ["2"] (command-context "chat-b"))
560+
result (f.commands/handle-command! "delete-chat" ["2" "confirm"] (command-context "chat-b"))]
561+
(is (string/includes? (get-in ask [:chats "chat-b" :messages 0 :content 0 :text])
562+
"Run `/delete-chat 2 confirm` to proceed."))
563+
(is (= {:type :delete-chat :target-chat-id "chat-c" :text "Deleted chat: Third"}
564+
result))))
565+
566+
(testing "a literal chat id is accepted"
567+
(h/reset-components!)
568+
(swap! (h/db*) assoc :chats {"chat-a" {:id "chat-a" :created-at 1 :title "First" :messages []}
569+
"chat-b" {:id "chat-b" :created-at 2 :messages []}})
570+
(is (= {:type :delete-chat :target-chat-id "chat-a" :text "Deleted chat: First"}
571+
(f.commands/handle-command! "delete-chat" ["chat-a" "confirm"] (command-context "chat-b")))))
572+
573+
(testing "unknown chat id"
574+
(h/reset-components!)
575+
(swap! (h/db*) assoc :chats {"chat-b" {:id "chat-b" :messages []}})
576+
(let [result (f.commands/handle-command! "delete-chat" ["9" "confirm"] (command-context "chat-b"))]
577+
(is (string/includes? (get-in result [:chats "chat-b" :messages 0 :content 0 :text])
578+
"Chat ID not found."))))
579+
580+
(testing "refuses deleting another chat with a running prompt"
581+
(h/reset-components!)
582+
(swap! (h/db*) assoc :chats {"chat-a" {:id "chat-a" :created-at 1 :title "Busy" :status :running :messages []}
583+
"chat-b" {:id "chat-b" :created-at 2 :messages []}})
584+
(let [result (f.commands/handle-command! "delete-chat" ["1" "confirm"] (command-context "chat-b"))]
585+
(is (string/includes? (get-in result [:chats "chat-b" :messages 0 :content 0 :text])
586+
"has a running prompt"))))
587+
588+
(testing "stale running status of an index-only chat does not block deletion"
589+
(h/reset-components!)
590+
(swap! (h/db*) assoc :chats {"chat-a" {:id "chat-a" :created-at 1 :title "Stale" :status :running :index-only? true}
591+
"chat-b" {:id "chat-b" :created-at 2 :messages []}})
592+
(is (= {:type :delete-chat :target-chat-id "chat-a" :text "Deleted chat: Stale"}
593+
(f.commands/handle-command! "delete-chat" ["1" "confirm"] (command-context "chat-b"))))))
594+
539595
(deftest rules-command-test
540596
(testing "/rules shows static and path-scoped rules separately with filter metadata"
541597
(with-redefs [f.rules/all-rules (fn [_config _roots agent full-model]

0 commit comments

Comments
 (0)