diff --git a/CHANGELOG.md b/CHANGELOG.md index 7069f2bb2..6d8c7c191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Retry transient OpenAI Responses `response.failed` server errors before output, preserving structured error and request IDs. - Fix prompt cache invalidation warning after clearing the chat and changing model. #530 - Fix missing line break after "Prompt stopped" message when followed by another system message. +- Scope restored model, variant, and trust selections to the opened, resumed, or imported chat instead of changing other chats and session defaults. +- Return an atomic model, agent, variant, variants, and trust selection snapshot from `chat/open`. ## 0.147.0 diff --git a/docs/protocol.md b/docs/protocol.md index 6c8c12fc6..2c6057ec4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2084,13 +2084,17 @@ A client request to hydrate a previously-persisted chat so it can be rendered in the UI. The server replays the chat by emitting `chat/cleared` (messages), `chat/opened`, and a sequence of `chat/contentReceived` notifications matching the persisted messages. When the persisted chat has a stored model the server -additionally emits a `config/updated` notification to realign the client's -selected model (and available variants) with the resumed chat, so the next -prompt keeps using the chat's original provider/model. The same notification -also carries `selectTrust` reflecting the resumed chat's trust toggle, so the -client indicator stays in sync with the auto-approval behavior the server will -apply. Typically used after `chat/list` when the user selects a chat that has -not been opened in the current client session. +additionally emits a `config/updated` notification scoped with the opened +`chatId` to realign that chat's selected model (and available variants), so the +next prompt keeps using the chat's original provider/model without changing +other chats or session defaults. A second scoped update carries `selectTrust` +reflecting the resumed chat's trust toggle, so the client indicator stays in +sync with the auto-approval behavior the server will apply. The response also +returns these values together with the effective agent in one atomic +`selection` snapshot; clients should prefer it when available and may retain +the scoped notifications as backward-compatible updates. Typically used after +`chat/list` when the user selects a chat that has not been opened in the current +client session. By default the full history is replayed. A client may instead pass the optional `limit`/`before`/`after` window parameters (same cursors as `chat/history`) to @@ -2140,6 +2144,18 @@ interface ChatOpenResponse { /** The chat title at the time of replay, when available. */ title?: string; + /** + * Atomic effective selection for the opened chat. A missing or unavailable + * persisted model is represented as null with no variants selected. + */ + selection?: { + model: string | null; + agent: string; + variant: string | null; + variants: string[]; + trust: boolean; + }; + /** Pagination metadata, present only when window params were supplied. */ meta?: ChatHistoryMeta; @@ -2695,9 +2711,9 @@ interface ConfigUpdatedParams { * should forcefully update the chat trust indicator (and any * derived UI like a shield/flame icon) to match this value. * - * Server returns this on chat resume (`chat/open`, `/resume`) so the - * client's indicator matches the auto-approval behavior the server - * will apply for subsequent tool calls in the resumed chat. + * Server returns this in a chat-scoped update on chat restore + * (`chat/open`, `/resume`, `/import`) so the client's indicator matches + * the auto-approval behavior for that chat without changing others. */ selectTrust?: boolean; diff --git a/integration-test/entrypoint.clj b/integration-test/entrypoint.clj index 86dca23a5..3c39786ee 100755 --- a/integration-test/entrypoint.clj +++ b/integration-test/entrypoint.clj @@ -22,6 +22,7 @@ integration.chat.mcp-remote-test integration.chat.background-jobs-test integration.chat.subagent-test + integration.chat.list-test integration.rewrite.openai-test]) (defn timeout [timeout-ms callback] diff --git a/integration-test/integration/chat/list_test.clj b/integration-test/integration/chat/list_test.clj index 0f098954d..4bf3dffab 100644 --- a/integration-test/integration/chat/list_test.clj +++ b/integration-test/integration/chat/list_test.clj @@ -57,7 +57,14 @@ (let [open-resp (eca/request! [:chat/open {:chat-id chat-id}]) cleared (eca/client-awaits-server-notification :chat/cleared) opened (eca/client-awaits-server-notification :chat/opened)] - (is (match? {:found true :chatId chat-id} open-resp)) + (is (match? {:found true + :chatId chat-id + :selection {:model "openai/gpt-4.1" + :agent "code" + :variant nil + :variants [] + :trust false}} + open-resp)) (is (match? {:chatId chat-id :messages true} cleared)) (is (match? {:chatId chat-id} opened)) ;; At least one content-received notification must follow diff --git a/src/eca/config.clj b/src/eca/config.clj index 6a999fcd9..c0f360940 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -809,18 +809,24 @@ the client-side selected model to `full-model`, re-computing the available variants and the suggested selected variant. Emits via `notify-fields-changed-only!`, so it is a no-op when nothing changed. - Returns nil when `full-model` is missing or no longer in `(:models @db*)`, - so a stale persisted model does not bubble to the UI. Used by chat resume - flows (`chat/open`, `/resume`) to restore the model each chat was using. + Returns the effective `{:model :variant :variants}` selection, or nil when + `full-model` is missing or no longer in `(:models @db*)`, so a stale + persisted model does not bubble to the UI. Used by chat resume flows + (`chat/open`, `/resume`) to restore the model each chat was using. When `chat-variant` is provided (the chat's persisted `:variant`) and is still supported by `full-model`, the broadcast keeps it; otherwise it falls back to the default agent's configured variant when valid, else nil. This lets resume preserve the variant the user last saw on the - chat instead of resetting to the default agent's variant." + chat instead of resetting to the default agent's variant. + + When `chat-id` is provided, the broadcast is scoped to that chat and does + not update the session-level config mirror." ([full-model db* messenger config] - (notify-selected-model-changed! full-model db* messenger config nil)) + (notify-selected-model-changed! full-model db* messenger config nil nil)) ([full-model db* messenger config chat-variant] + (notify-selected-model-changed! full-model db* messenger config chat-variant nil)) + ([full-model db* messenger config chat-variant chat-id] (when (and full-model (contains? (:models @db*) full-model)) (let [default-agent-name (validate-agent-name (or (:defaultAgent (:chat config)) @@ -839,13 +845,17 @@ select-variant (cond (valid? chat-variant) chat-variant (valid? agent-variant) agent-variant - :else nil)] - (notify-fields-changed-only! - {:chat {:select-model full-model - :variants (or variants []) - :select-variant select-variant}} - messenger - db*))))) + :else nil) + selection {:model full-model + :variants (or variants []) + :variant select-variant} + payload {:chat {:select-model (:model selection) + :variants (:variants selection) + :select-variant (:variant selection)}}] + (if chat-id + (notify-fields-changed-only! payload messenger db* chat-id) + (notify-fields-changed-only! payload messenger db*)) + selection)))) (defn notify-selected-trust-changed! "Server-initiated equivalent of a client-side trust toggle: aligns the @@ -853,12 +863,20 @@ Emits via `notify-fields-changed-only!`, so it is a no-op when nothing changed. Used by chat resume flows (`chat/open`, `/resume`) so the icon the client shows matches the auto-approval behavior the server is about - to apply (#426)." - [trust db* messenger] - (notify-fields-changed-only! - {:chat {:select-trust (boolean trust)}} - messenger - db*)) + to apply (#426). + + When `chat-id` is provided, the broadcast is scoped to that chat and does + not update the session-level config mirror. Returns the normalized boolean + trust selection." + ([trust db* messenger] + (notify-selected-trust-changed! trust db* messenger nil)) + ([trust db* messenger chat-id] + (let [selection (boolean trust) + payload {:chat {:select-trust selection}}] + (if chat-id + (notify-fields-changed-only! payload messenger db* chat-id) + (notify-fields-changed-only! payload messenger db*)) + selection))) (def ^:private config-schema-url "https://eca.dev/config.json") diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index b7106eb9b..0495aad79 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -2105,15 +2105,16 @@ "Replay a persisted chat over the wire so a freshly-started client can render it. Emits `chat/cleared` (messages) followed by `chat/opened` and streams each persisted message via `send-chat-contents!`. Also re-aligns the client's - selected model to the resumed chat's stored `:model` via a `config/updated` - notification, so an Opus-started chat keeps using Opus on the next prompt - (#417). Performs no DB mutation otherwise. + selected model to the resumed chat's stored `:model` via a scoped + `config/updated` notification and returns the restored model, agent, variant, + variants, and trust as an atomic `:selection` snapshot (#417). Performs no + DB mutation otherwise. Optional `:limit`/`:before`/`:after` window the replay (same cursors as `chat/history`); when provided, the response includes `:meta` so the client can page older via `chat/history`. Without them the full history is replayed. - Returns `{:found? false}` when the chat does not exist or is a subagent, - otherwise `{:found? true :chat-id ... :title ... :meta? ...}`." + Returns `{:found false}` when the chat does not exist or is a subagent, + otherwise `{:found true :chat-id ... :title ... :selection ... :meta? ...}`." [{:keys [chat-id limit before after] :as params} db* messenger config] (let [chat (get-in @db* [:chats chat-id]) windowed? (some #(contains? params %) [:limit :before :after]) @@ -2122,10 +2123,10 @@ {:limit limit :before before :after after}))] (cond (or (nil? chat) (:subagent chat)) - {:found? false} + {:found false} (= :cursor-expired (:error page)) - {:found? true :chat-id chat-id + {:found true :chat-id chat-id :error {:code "cursor_expired" :message "Cursor no longer points to an existing message; refetch the latest page"}} @@ -2138,15 +2139,29 @@ (messenger/chat-opened messenger (assoc-some {:chat-id chat-id} :title title)) (send-chat-contents! messages chat-ctx) (lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title)) - (config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat)) - (config/notify-selected-trust-changed! (:trust chat) db* messenger) - (assoc-some {:found? true :chat-id chat-id :title title} - :meta (when windowed? - {:total (:total page) - :returned (:returned page) - :before-cursor (:before-cursor page) - :after-cursor (:after-cursor page) - :compaction-cursor (history/compaction-cursor (:messages chat))})))))) + (let [model-selection (config/notify-selected-model-changed! + (:model chat) db* messenger config (:variant chat) chat-id) + trust-selection (config/notify-selected-trust-changed! + (:trust chat) db* messenger chat-id) + agent-selection (config/validate-agent-name + (or (:agent chat) + (:defaultAgent (:chat config)) + (:defaultAgent config)) + config) + selection (merge {:model nil :variant nil :variants []} + model-selection + {:agent agent-selection + :trust trust-selection})] + (assoc-some {:found true + :chat-id chat-id + :title title + :selection selection} + :meta (when windowed? + {:total (:total page) + :returned (:returned page) + :before-cursor (:before-cursor page) + :after-cursor (:after-cursor page) + :compaction-cursor (history/compaction-cursor (:messages chat))}))))))) (defn fetch-history "Window a chat's persisted messages and return the transformed content items diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index b80595fd3..8d23def42 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -752,11 +752,11 @@ (db/update-workspaces-cache! @db* metrics) ;; Align the client's selected model with the resumed chat ;; so the LLM call keeps using the chat's original model. #417 - (config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat)) + (config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat) chat-id) ;; Align the client's trust indicator with the resumed ;; chat's persisted :trust so the icon matches the ;; auto-approval behavior the server will apply. #426 - (config/notify-selected-trust-changed! (:trust chat) db* messenger) + (config/notify-selected-trust-changed! (:trust chat) db* messenger chat-id) {:type :chat-messages :clear-before? true :chats {chat-id {:title (:title chat) @@ -893,8 +893,8 @@ (db/update-workspaces-cache! @db* metrics) (messenger/chat-opened messenger {:chat-id imported-id :title (:title imported-chat)}) ;; Align the client's model/trust with the imported chat (like /resume). - (config/notify-selected-model-changed! (:model imported-chat) db* messenger config (:variant imported-chat)) - (config/notify-selected-trust-changed! (:trust imported-chat) db* messenger) + (config/notify-selected-model-changed! (:model imported-chat) db* messenger config (:variant imported-chat) imported-id) + (config/notify-selected-trust-changed! (:trust imported-chat) db* messenger imported-id) {:type :chat-messages :chats {imported-id {:title (:title imported-chat) :messages (concat [{:role "system" :content [{:type :text :text summary}]}] diff --git a/src/eca/handlers.clj b/src/eca/handlers.clj index a5ce2a420..951da8258 100644 --- a/src/eca/handlers.clj +++ b/src/eca/handlers.clj @@ -282,8 +282,8 @@ (defn chat-open "Replay a persisted chat over the wire for the client to render. Emits chat/cleared, chat/opened and per-message chat/contentReceived - notifications for the target chat, plus a config/updated notification - to restore the chat's stored model selection. Returns `{:found? bool ...}`." + notifications for the target chat, plus scoped config/updated notifications + to restore the chat's stored selection. Returns `{:found bool ...}`." [{:keys [db* messenger config metrics]} params] (metrics/task metrics :eca/chat-open (f.chat/open-chat! params db* messenger config))) diff --git a/test/eca/config_test.clj b/test/eca/config_test.clj index dfd9b9de9..36461ac82 100644 --- a/test/eca/config_test.clj +++ b/test/eca/config_test.clj @@ -852,7 +852,46 @@ (h/db*) (h/messenger) (h/config) nil) (is (match? {:config-updated [{:chat {:select-variant "high"}}]} - (h/messages))))) + (h/messages)))) + + (testing "6-arity: chat-id scopes the model and variant without changing session defaults" + (h/reset-components!) + (h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5" + {:variants {"low" {:a 1} + "high" {:a 2}}}}}} + :defaultAgent "code" + :agent {"code" {:variant "high"}}}) + (swap! (h/db*) assoc + :models {"anthropic/claude-sonnet-4-5" {:tools true}} + :last-config-notified {:chat {:select-model "openai/gpt-5.2" + :select-variant "medium"}}) + (let [session-defaults (:last-config-notified (h/db)) + selection (config/notify-selected-model-changed! + "anthropic/claude-sonnet-4-5" + (h/db*) (h/messenger) (h/config) + "low" "chat-a")] + (is (= {:model "anthropic/claude-sonnet-4-5" + :variants ["high" "low"] + :variant "low"} + selection)) + (is (match? {:config-updated [{:chat-id "chat-a" + :chat {:select-model "anthropic/claude-sonnet-4-5" + :variants ["high" "low"] + :select-variant "low"}}]} + (h/messages))) + (is (= session-defaults (:last-config-notified (h/db))))))) + +(deftest notify-selected-trust-changed-test + (testing "4-arity: chat-id scopes trust without changing session defaults" + (h/reset-components!) + (swap! (h/db*) assoc :last-config-notified {:chat {:select-trust false}}) + (let [session-defaults (:last-config-notified (h/db)) + selection (config/notify-selected-trust-changed! + true (h/db*) (h/messenger) "chat-a")] + (is (true? selection)) + (is (= [{:chat-id "chat-a" :chat {:select-trust true}}] + (:config-updated (h/messages)))) + (is (= session-defaults (:last-config-notified (h/db))))))) (deftest agent-modes-test (testing "scalar string :mode is honored" diff --git a/test/eca/features/chat_test.clj b/test/eca/features/chat_test.clj index 039de8c3b..55426f7c5 100644 --- a/test/eca/features/chat_test.clj +++ b/test/eca/features/chat_test.clj @@ -1767,12 +1767,65 @@ :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) (let [result (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config))] - (is (match? {:found? true :chat-id chat-id :title "My Opus thread"} result)) - (is (match? {:config-updated [{:chat {:select-model "anthropic/claude-opus-4" + (is (match? {:found true + :chat-id chat-id + :title "My Opus thread" + :selection {:model "anthropic/claude-opus-4" + :agent "code" + :variant nil + :variants [] + :trust false}} + result)) + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-model "anthropic/claude-opus-4" :variants [] :select-variant nil}} - {:chat {:select-trust false}}]} - (h/messages)))))) + {:chat-id chat-id + :chat {:select-trust false}}]} + (h/messages))) + (is (= {} (:last-config-notified (h/db))))))) + + (testing "Opening a chat returns its persisted agent and valid variant atomically" + (h/reset-components!) + (h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5" + {:variants {"low" {:effort "low"} + "high" {:effort "high"}}}}}} + :defaultAgent "code" + :agent {"code" {} "plan" {}}}) + (let [chat-id "planned"] + (swap! (h/db*) assoc + :models {"anthropic/claude-sonnet-4-5" {:tools true}} + :chats {chat-id {:id chat-id + :model "anthropic/claude-sonnet-4-5" + :agent "plan" + :variant "low" + :trust true + :messages []}}) + (let [result (f.chat/open-chat! {:chat-id chat-id} + (h/db*) (h/messenger) (h/config))] + (is (= {:model "anthropic/claude-sonnet-4-5" + :agent "plan" + :variant "low" + :variants ["high" "low"] + :trust true} + (:selection result)))))) + + (testing "Opening a chat returns the default agent's valid variant when the persisted one is stale" + (h/reset-components!) + (h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5" + {:variants {"low" {:effort "low"} + "high" {:effort "high"}}}}}} + :agent {"code" {:variant "high"}}}) + (let [chat-id "stale-variant"] + (swap! (h/db*) assoc + :models {"anthropic/claude-sonnet-4-5" {:tools true}} + :chats {chat-id {:id chat-id + :model "anthropic/claude-sonnet-4-5" + :variant "max" + :messages []}}) + (let [result (f.chat/open-chat! {:chat-id chat-id} + (h/db*) (h/messenger) (h/config))] + (is (= "high" (get-in result [:selection :variant])))))) (testing "Opening a chat with no stored :model only emits trust config/updated" (h/reset-components!) @@ -1780,8 +1833,16 @@ (swap! (h/db*) assoc-in [:chats chat-id] {:id chat-id :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) - (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) - (is (match? {:config-updated [{:chat {:select-trust false}}]} + (let [result (f.chat/open-chat! {:chat-id chat-id} + (h/db*) (h/messenger) (h/config))] + (is (= {:model nil + :agent "code" + :variant nil + :variants [] + :trust false} + (:selection result)))) + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-trust false}}]} (h/messages))))) (testing "Opening a chat with a stale stored :model only emits trust config/updated" @@ -1792,8 +1853,13 @@ {:id chat-id :model "anthropic/claude-opus-4" :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) - (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) - (is (match? {:config-updated [{:chat {:select-trust false}}]} + (let [result (f.chat/open-chat! {:chat-id chat-id} + (h/db*) (h/messenger) (h/config))] + (is (nil? (get-in result [:selection :model]))) + (is (= [] (get-in result [:selection :variants]))) + (is (nil? (get-in result [:selection :variant])))) + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-trust false}}]} (h/messages)))))) (deftest open-chat-marks-editor-open-test @@ -1817,7 +1883,8 @@ :trust true :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) - (is (match? {:config-updated [{:chat {:select-trust true}}]} + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-trust true}}]} (h/messages))))) (testing "Opening a non-trusted chat emits config/updated select-trust false (#426)" @@ -1830,7 +1897,8 @@ :trust false :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) - (is (match? {:config-updated [{:chat {:select-trust false}}]} + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-trust false}}]} (h/messages))))) (testing "Opening a chat with no :trust key normalizes to false (#426)" @@ -1841,7 +1909,8 @@ {:id chat-id :messages [{:role "user" :content [{:type :text :text "hi"}]}]}) (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) - (is (match? {:config-updated [{:chat {:select-trust false}}]} + (is (match? {:config-updated [{:chat-id chat-id + :chat {:select-trust false}}]} (h/messages)))))) (deftest fetch-history-test @@ -1911,7 +1980,7 @@ (swap! (h/db*) assoc-in [:chats "win"] {:id "win" :messages [(msg "m0" 1) (msg "m1" 2) (msg "m2" 3)]}) (let [result (f.chat/open-chat! {:chat-id "win" :limit 2} (h/db*) (h/messenger) (h/config))] - (is (match? {:found? true :chat-id "win" + (is (match? {:found true :chat-id "win" :meta {:total 3 :returned 2 :after-cursor nil}} result)) (is (some? (get-in result [:meta :before-cursor]))) diff --git a/test/eca/features/commands_test.clj b/test/eca/features/commands_test.clj index e96b70cb0..3faac536e 100644 --- a/test/eca/features/commands_test.clj +++ b/test/eca/features/commands_test.clj @@ -3,6 +3,8 @@ [babashka.fs :as fs] [clojure.string :as string] [clojure.test :refer [deftest is testing]] + [eca.db :as db] + [eca.features.chat.export :as f.chat.export] [eca.features.commands :as f.commands] [eca.features.rules :as f.rules] [eca.shared :as shared] @@ -10,6 +12,18 @@ (h/reset-components-before-test) +(defn ^:private command-context [chat-id] + {:chat-id chat-id + :db* (h/db*) + :config (h/config) + :messenger (h/messenger) + :full-model "openai/gpt-5.2" + :agent "code" + :all-tools [] + :instructions {} + :user-messages [] + :metrics (h/metrics)}) + (deftest prompt-show-text-test (testing "uses compact section headings and omits the /prompt-show command itself" (let [result (#'f.commands/prompt-show-text @@ -345,6 +359,74 @@ (is (re-find #"Unknown model: `bad/model`" (get-in result [:chats "chat-1" :messages 0 :content 0 :text])))))) +(deftest restore-command-selection-scoping-test + (testing "/resume scopes restored selection to the current chat" + (h/reset-components!) + (h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5" + {:variants {"low" {:effort "low"} + "high" {:effort "high"}}}}}}}) + (swap! (h/db*) assoc + :models {"anthropic/claude-sonnet-4-5" {:tools true}} + :last-config-notified {:chat {:select-model "openai/gpt-5.2" + :select-variant "medium" + :select-trust false}} + :chats {"chat-a" {:id "chat-a" + :created-at 1 + :model "anthropic/claude-sonnet-4-5" + :variant "low" + :trust true + :messages []} + "chat-b" {:id "chat-b" + :created-at 2 + :model "openai/gpt-5.2" + :messages []}}) + (let [session-defaults (:last-config-notified (h/db))] + (with-redefs [db/update-workspaces-cache! (fn [& _])] + (f.commands/handle-command! "resume" ["1"] (command-context "chat-b"))) + (is (= [{:chat-id "chat-b" + :chat {:select-model "anthropic/claude-sonnet-4-5" + :variants ["high" "low"] + :select-variant "low"}} + {:chat-id "chat-b" + :chat {:select-trust true}}] + (:config-updated (h/messages)))) + (is (= {:model "anthropic/claude-sonnet-4-5" + :variant "low" + :trust true} + (select-keys (get-in (h/db) [:chats "chat-b"]) + [:model :variant :trust]))) + (is (= session-defaults (:last-config-notified (h/db)))))) + + (testing "/import scopes restored selection to the imported chat" + (h/reset-components!) + (h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5" + {:variants {"low" {:effort "low"} + "high" {:effort "high"}}}}}}}) + (swap! (h/db*) assoc + :models {"anthropic/claude-sonnet-4-5" {:tools true}} + :last-config-notified {:chat {:select-model "openai/gpt-5.2" + :select-variant "medium" + :select-trust false}} + :chats {"chat-b" {:id "chat-b" :messages []}}) + (let [session-defaults (:last-config-notified (h/db)) + imported-chat {:id "chat-a" + :title "Imported" + :model "anthropic/claude-sonnet-4-5" + :variant "low" + :trust true + :messages []}] + (with-redefs [db/update-workspaces-cache! (fn [& _]) + f.chat.export/import-chat! (fn [_] {:chat imported-chat})] + (f.commands/handle-command! "import" ["/tmp/chat.edn"] (command-context "chat-b"))) + (is (= [{:chat-id "chat-a" + :chat {:select-model "anthropic/claude-sonnet-4-5" + :variants ["high" "low"] + :select-variant "low"}} + {:chat-id "chat-a" + :chat {:select-trust true}}] + (:config-updated (h/messages)))) + (is (= session-defaults (:last-config-notified (h/db))))))) + (deftest rules-command-test (testing "/rules shows static and path-scoped rules separately with filter metadata" (with-redefs [f.rules/all-rules (fn [_config _roots agent full-model] diff --git a/test/eca/handlers_test.clj b/test/eca/handlers_test.clj index 86541c4f0..ac71e6cfa 100644 --- a/test/eca/handlers_test.clj +++ b/test/eca/handlers_test.clj @@ -620,9 +620,9 @@ {:sort-by :created-at}))))))) (deftest chat-open-test - (testing "Unknown chat returns {:found? false} and emits no messages" + (testing "Unknown chat returns {:found false} and emits no messages" (h/reset-components!) - (is (match? {:found? false} + (is (match? {:found false} (handlers/chat-open (h/components) {:chat-id "missing"}))) (is (nil? (:chat-opened (h/messages))))) @@ -631,20 +631,29 @@ (seed-chats! {"sub" {:id "sub" :status :idle :subagent true :parent-chat-id "main" :messages [] :created-at 1 :updated-at 1}}) - (is (match? {:found? false} + (is (match? {:found false} (handlers/chat-open (h/components) {:chat-id "sub"}))) (is (nil? (:chat-opened (h/messages))))) (testing "Known chat emits chat/cleared + chat/opened and returns found? true" (h/reset-components!) + (h/config! {:defaultAgent "code" + :agent {"code" {} "plan" {}}}) + (swap! (h/db*) assoc :models {"openai/gpt-5.2" {:tools true}}) (seed-chats! {"c1" {:id "c1" :title "Hello world" :status :idle :created-at 10 :updated-at 20 + :model "openai/gpt-5.2" :agent "plan" :trust true :messages [{:role "user" :content [{:type :text :text "hi"}]}]}}) (let [result (handlers/chat-open (h/components) {:chat-id "c1"})] - (is (match? {:found? true + (is (match? {:found true :chat-id "c1" - :title "Hello world"} + :title "Hello world" + :selection {:model "openai/gpt-5.2" + :agent "plan" + :variant nil + :variants [] + :trust true}} result)) (is (match? {:chat-clear [{:chat-id "c1" :messages true}] :chat-opened [{:chat-id "c1" :title "Hello world"}]}