From eefffe04a21ad0217f67070640f999092d190677 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Wed, 3 Jun 2026 10:21:36 -0400 Subject: [PATCH 1/2] Stamp :id into every persisted chat value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chats persisted before they were seeded with an :id had no :id inside their stored value (the map key was the only authoritative id). The remote endpoints filter/identify chats via (:id chat), so such chats — typically the ones being resumed on a fresh server — were permanently absent from GET /session and GET /chats and never corrected. Guarantee the invariant at the source: stamp :id from the map key when writing the workspace cache, and backfill it on load so existing legacy caches are healed in-memory for the current session. --- CHANGELOG.md | 2 ++ src/eca/db.clj | 33 +++++++++++++++++++-------------- test/eca/db_test.clj | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 046fff155..8f21124da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 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) diff --git a/src/eca/db.clj b/src/eca/db.clj index d129fc348..b66176ae2 100644 --- a/src/eca/db.clj +++ b/src/eca/db.clj @@ -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)] @@ -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])) diff --git a/test/eca/db_test.clj b/test/eca/db_test.clj index c70bde15e..5d58ae0a6 100644 --- a/test/eca/db_test.clj +++ b/test/eca/db_test.clj @@ -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))] From 3a1703823a9ca593de4fcb5aae8fdd47b8c9f1a6 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Wed, 3 Jun 2026 10:31:56 -0400 Subject: [PATCH 2/2] List resumed/forked chats on the remote endpoint immediately The remote GET /session and GET /chats endpoints only showed chats the editor had open this run, tracked via :chat-start-fired. That flag is only set on the first prompt, so a resumed (chat/open) or forked chat stayed hidden until the user prompted it. Track editor-open chats in a dedicated :editor-open-chats set, marked on prompt, resume and fork, and filter the endpoints on it. Keep :chat-start-fired solely for gating the one-shot chatStart hook, so resumed chats still fire chatStart with :resumed true on first prompt. --- CHANGELOG.md | 1 + src/eca/features/chat.clj | 9 +++++++++ src/eca/remote/handlers.clj | 8 ++++---- test/eca/features/chat_test.clj | 11 +++++++++++ test/eca/remote/handlers_test.clj | 6 +++--- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f21124da..adf894aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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 diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 048deecfd..6bccab7c9 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -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)) @@ -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}) @@ -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) diff --git a/src/eca/remote/handlers.clj b/src/eca/remote/handlers.clj index bb9eb99fe..b677d2946 100644 --- a/src/eca/remote/handlers.clj +++ b/src/eca/remote/handlers.clj @@ -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))) @@ -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))) diff --git a/test/eca/features/chat_test.clj b/test/eca/features/chat_test.clj index 077f5177e..96a91420b 100644 --- a/test/eca/features/chat_test.clj +++ b/test/eca/features/chat_test.clj @@ -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!) diff --git a/test/eca/remote/handlers_test.clj b/test/eca/remote/handlers_test.clj index bb428c67f..537d16bfd 100644 --- a/test/eca/remote/handlers_test.clj +++ b/test/eca/remote/handlers_test.clj @@ -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))) @@ -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))] @@ -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))]