Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Remote endpoint now lists resumed and forked chats immediately, without waiting for the first prompt.
- Fix resumed chats being permanently missing from the remote `GET /session` and `GET /chats` endpoints.

## 0.138.1

- Allow overriding a model's context/output token limits and pricing per model in config (`limit`/`cost`), enabling the usage display and auto-compaction for local/custom models. (#46)
Expand Down
33 changes: 19 additions & 14 deletions src/eca/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,12 @@
(catch Throwable e
(logger/warn logger-tag "Could not consolidate workspace cache" e))))

(defn stamp-chat-ids
"Ensures every chat value carries its map key as :id, so readers can rely on
it. Heals legacy rows persisted before chats were seeded with an :id."
[chats]
(reduce-kv (fn [m k v] (assoc m k (assoc v :id k))) {} chats))

(defn load-db-from-cache! [db* config metrics]
(when-not (:pureConfig config)
(when-let [global-cache (read-global-cache metrics)]
Expand All @@ -315,24 +321,23 @@
(consolidate-workspace-cache! workspaces metrics)
(when-let [global-by-workspace-cache (read-global-by-workspaces-cache workspaces metrics)]
(logger/info logger-tag "Loading from workspace-cache caches...")
(swap! db* shared/deep-merge global-by-workspace-cache)))))
(swap! db* shared/deep-merge global-by-workspace-cache)))
(swap! db* update :chats stamp-chat-ids)))

(defn ^:private normalize-db-for-workspace-write [db]
(-> (select-keys db [:chats])
(update :chats (fn [chats]
(into {}
;; Persist every chat that lives in memory.
;; We used to drop chats with empty :messages
;; here, but that erased chats that were
;; intentionally rolled back to empty and also
;; (combined with the late add-to-history!
;; behaviour) erased chats that hit a provider
;; error before any token arrived. Cleanup of
;; stale chats is handled by
;; cleanup-old-chats! instead.
(map (fn [[k v]]
[k (dissoc v :tool-calls)]))
chats)))))
;; Persist every chat that lives in memory.
;; We used to drop chats with empty :messages
;; here, but that erased chats that were
;; intentionally rolled back to empty and also
;; (combined with the late add-to-history!
;; behaviour) erased chats that hit a provider
;; error before any token arrived. Cleanup of
;; stale chats is handled by
;; cleanup-old-chats! instead.
(-> (update-vals chats #(dissoc % :tool-calls))
stamp-chat-ids)))))

(defn ^:private normalize-db-for-global-write [db]
(select-keys db [:auth :mcp-auth]))
Expand Down
9 changes: 9 additions & 0 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1178,12 +1178,19 @@
:text (str "Error: " (ex-message e) "\n\nCheck ECA stderr for more details.")})
(lifecycle/finish-chat-prompt! :idle (dissoc chat-ctx :on-finished-side-effect)))))

(defn ^:private mark-editor-open!
"Records that the editor has this chat open this run, so the remote endpoint
lists it. Separate from :chat-start-fired, which only gates the chatStart hook."
[db* chat-id]
(swap! db* update :editor-open-chats (fnil conj #{}) chat-id))

(defn ^:private prompt*
[{:keys [model]}
{:keys [chat-id contexts message agent agent-config db* messenger config metrics] :as base-chat-ctx}]
(let [provided-chat-id chat-id
;; Snapshot DB to detect new/resumed chat BEFORE hooks mutate it
[db0 _] (swap-vals! db* assoc-in [:chat-start-fired chat-id] true)
_ (mark-editor-open! db* chat-id)
existing-chat-before-prompt (get-in db0 [:chats chat-id])
chat-start-fired? (get-in db0 [:chat-start-fired chat-id])
has-messages? (seq (:messages existing-chat-before-prompt))
Expand Down Expand Up @@ -1679,6 +1686,7 @@
:messages kept-messages
:prompt-finished? true}]
(swap! db* assoc-in [:chats new-id] new-chat)
(mark-editor-open! db* new-id)
(db/update-workspaces-cache! @db* metrics)
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
(send-chat-contents! kept-messages {:chat-id new-id :db* db* :messenger messenger})
Expand Down Expand Up @@ -1734,6 +1742,7 @@
(let [title (:title chat)
messages (:messages chat)
chat-ctx {:chat-id chat-id :db* db* :messenger messenger}]
(mark-editor-open! db* chat-id)
(messenger/chat-cleared messenger {:chat-id chat-id :messages true})
(messenger/chat-opened messenger (assoc-some {:chat-id chat-id} :title title))
(send-chat-contents! messages chat-ctx)
Expand Down
8 changes: 4 additions & 4 deletions src/eca/remote/handlers.clj
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@
:mcpServers (mapv (fn [[name client-info]]
{:name name :status (or (:status client-info) "unknown")})
(:mcp-clients db))
:chats (let [editor-open (:chat-start-fired db)]
:chats (let [editor-open (:editor-open-chats db)]
(->> (vals (:chats db))
(remove :subagent)
(filter #(get editor-open (:id %)))
(filter #(contains? editor-open (:id %)))
(mapv chat-summary)))
:startedAt (when-let [ms (:started-at db)]
(.toString (Instant/ofEpochMilli ^long ms)))
Expand Down Expand Up @@ -151,10 +151,10 @@

(defn handle-list-chats [{:keys [db*]} _request]
(let [db @db*
editor-open (:chat-start-fired db)
editor-open (:editor-open-chats db)
chats (->> (vals (:chats db))
(remove :subagent)
(filter #(get editor-open (:id %)))
(filter #(contains? editor-open (:id %)))
(mapv chat-summary))]
(json-response chats)))

Expand Down
18 changes: 18 additions & 0 deletions test/eca/db_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,24 @@
(testing ":tool-calls runtime state is stripped before persisting"
(is (not (contains? (get-in result [:chats "with-msg"]) :tool-calls))))))

(deftest stamp-chat-ids-test
(testing "every chat value gets its map key as :id"
(is (= {"a" {:id "a" :title "A"}
"b" {:id "b"}}
(db/stamp-chat-ids {"a" {:title "A"}
"b" {}}))))
(testing "a stale/mismatched :id is corrected to the map key"
(is (= {"a" {:id "a"}}
(db/stamp-chat-ids {"a" {:id "wrong"}}))))
(testing "nil chats normalize to an empty map"
(is (= {} (db/stamp-chat-ids nil)))))

(deftest normalize-stamps-chat-id-test
(let [normalize @#'db/normalize-db-for-workspace-write
result (normalize {:chats {"legacy" {:title "no id in value"}}})]
(testing "persisted chats always carry their :id"
(is (= "legacy" (get-in result [:chats "legacy" :id]))))))

(deftest consolidate-workspace-cache!-merges-and-removes-redundant-dirs-test
(testing "merges chats from a hash-only dir into the canonical dir (newest wins) and removes the redundant dir"
(let [tmpdir (str (fs/create-temp-dir))]
Expand Down
11 changes: 11 additions & 0 deletions test/eca/features/chat_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,17 @@
(is (match? {:config-updated [{:chat {:select-trust false}}]}
(h/messages))))))

(deftest open-chat-marks-editor-open-test
(testing "Resuming a chat lists it on the remote endpoint without a prompt"
(h/reset-components!)
(let [chat-id "resumed"]
(swap! (h/db*) assoc-in [:chats chat-id]
{:id chat-id
:messages [{:role "user" :content [{:type :text :text "hi"}]}]})
(is (not (contains? (:editor-open-chats @(h/db*)) chat-id)))
(f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config))
(is (contains? (:editor-open-chats @(h/db*)) chat-id)))))

(deftest open-chat-restores-selected-trust-test
(testing "Opening a trusted chat emits config/updated select-trust true (#426)"
(h/reset-components!)
Expand Down
6 changes: 3 additions & 3 deletions test/eca/remote/handlers_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
(testing "returns chats excluding subagents"
(swap! (h/db*) assoc :chats {"c1" {:id "c1" :title "Test" :status :idle :created-at 123}
"c2" {:id "c2" :title "Sub" :status :running :subagent true}}
:chat-start-fired #{"c1" "c2"})
:editor-open-chats #{"c1" "c2"})
(let [response (handlers/handle-list-chats (components) nil)
body (json/parse-string (:body response) true)]
(is (= 1 (count body)))
Expand All @@ -64,7 +64,7 @@
"tc-3" {:status :executing}
"tc-4" {:status :completed}}}
"c2" {:id "c2" :title "No pending" :status :idle}}
:chat-start-fired #{"c1" "c2"})
:editor-open-chats #{"c1" "c2"})
(let [response (handlers/handle-list-chats (components) nil)
body (json/parse-string (:body response) true)
by-id (into {} (map (juxt :id identity) body))]
Expand Down Expand Up @@ -202,7 +202,7 @@
:chats {"c1" {:id "c1" :title "Has pending" :status :running
:tool-calls {"tc-1" {:status :waiting-approval}
"tc-2" {:status :executing}}}}
:chat-start-fired #{"c1"})
:editor-open-chats #{"c1"})
(let [response (handlers/handle-session (components) nil)
body (json/parse-string (:body response) true)
chat (first (:chats body))]
Expand Down
Loading