Skip to content

Commit d3e901f

Browse files
authored
Merge pull request #533 from rschmukler/rs/fix-variant-broadcast-on-chat-open
Scope restored chat selections per chat
2 parents 1a68eb9 + e9f4a73 commit d3e901f

12 files changed

Lines changed: 327 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Retry transient OpenAI Responses `response.failed` server errors before output, preserving structured error and request IDs.
66
- Fix prompt cache invalidation warning after clearing the chat and changing model. #530
77
- Fix missing line break after "Prompt stopped" message when followed by another system message.
8+
- Scope restored model, variant, and trust selections to the opened, resumed, or imported chat instead of changing other chats and session defaults.
9+
- Return an atomic model, agent, variant, variants, and trust selection snapshot from `chat/open`.
810

911
## 0.147.0
1012

docs/protocol.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,13 +2084,17 @@ A client request to hydrate a previously-persisted chat so it can be rendered
20842084
in the UI. The server replays the chat by emitting `chat/cleared` (messages),
20852085
`chat/opened`, and a sequence of `chat/contentReceived` notifications matching
20862086
the persisted messages. When the persisted chat has a stored model the server
2087-
additionally emits a `config/updated` notification to realign the client's
2088-
selected model (and available variants) with the resumed chat, so the next
2089-
prompt keeps using the chat's original provider/model. The same notification
2090-
also carries `selectTrust` reflecting the resumed chat's trust toggle, so the
2091-
client indicator stays in sync with the auto-approval behavior the server will
2092-
apply. Typically used after `chat/list` when the user selects a chat that has
2093-
not been opened in the current client session.
2087+
additionally emits a `config/updated` notification scoped with the opened
2088+
`chatId` to realign that chat's selected model (and available variants), so the
2089+
next prompt keeps using the chat's original provider/model without changing
2090+
other chats or session defaults. A second scoped update carries `selectTrust`
2091+
reflecting the resumed chat's trust toggle, so the client indicator stays in
2092+
sync with the auto-approval behavior the server will apply. The response also
2093+
returns these values together with the effective agent in one atomic
2094+
`selection` snapshot; clients should prefer it when available and may retain
2095+
the scoped notifications as backward-compatible updates. Typically used after
2096+
`chat/list` when the user selects a chat that has not been opened in the current
2097+
client session.
20942098

20952099
By default the full history is replayed. A client may instead pass the optional
20962100
`limit`/`before`/`after` window parameters (same cursors as `chat/history`) to
@@ -2140,6 +2144,18 @@ interface ChatOpenResponse {
21402144
/** The chat title at the time of replay, when available. */
21412145
title?: string;
21422146

2147+
/**
2148+
* Atomic effective selection for the opened chat. A missing or unavailable
2149+
* persisted model is represented as null with no variants selected.
2150+
*/
2151+
selection?: {
2152+
model: string | null;
2153+
agent: string;
2154+
variant: string | null;
2155+
variants: string[];
2156+
trust: boolean;
2157+
};
2158+
21432159
/** Pagination metadata, present only when window params were supplied. */
21442160
meta?: ChatHistoryMeta;
21452161

@@ -2695,9 +2711,9 @@ interface ConfigUpdatedParams {
26952711
* should forcefully update the chat trust indicator (and any
26962712
* derived UI like a shield/flame icon) to match this value.
26972713
*
2698-
* Server returns this on chat resume (`chat/open`, `/resume`) so the
2699-
* client's indicator matches the auto-approval behavior the server
2700-
* will apply for subsequent tool calls in the resumed chat.
2714+
* Server returns this in a chat-scoped update on chat restore
2715+
* (`chat/open`, `/resume`, `/import`) so the client's indicator matches
2716+
* the auto-approval behavior for that chat without changing others.
27012717
*/
27022718
selectTrust?: boolean;
27032719

integration-test/entrypoint.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
integration.chat.mcp-remote-test
2323
integration.chat.background-jobs-test
2424
integration.chat.subagent-test
25+
integration.chat.list-test
2526
integration.rewrite.openai-test])
2627

2728
(defn timeout [timeout-ms callback]

integration-test/integration/chat/list_test.clj

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@
5757
(let [open-resp (eca/request! [:chat/open {:chat-id chat-id}])
5858
cleared (eca/client-awaits-server-notification :chat/cleared)
5959
opened (eca/client-awaits-server-notification :chat/opened)]
60-
(is (match? {:found true :chatId chat-id} open-resp))
60+
(is (match? {:found true
61+
:chatId chat-id
62+
:selection {:model "openai/gpt-4.1"
63+
:agent "code"
64+
:variant nil
65+
:variants []
66+
:trust false}}
67+
open-resp))
6168
(is (match? {:chatId chat-id :messages true} cleared))
6269
(is (match? {:chatId chat-id} opened))
6370
;; At least one content-received notification must follow

src/eca/config.clj

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -809,18 +809,24 @@
809809
the client-side selected model to `full-model`, re-computing the available
810810
variants and the suggested selected variant. Emits via
811811
`notify-fields-changed-only!`, so it is a no-op when nothing changed.
812-
Returns nil when `full-model` is missing or no longer in `(:models @db*)`,
813-
so a stale persisted model does not bubble to the UI. Used by chat resume
814-
flows (`chat/open`, `/resume`) to restore the model each chat was using.
812+
Returns the effective `{:model :variant :variants}` selection, or nil when
813+
`full-model` is missing or no longer in `(:models @db*)`, so a stale
814+
persisted model does not bubble to the UI. Used by chat resume flows
815+
(`chat/open`, `/resume`) to restore the model each chat was using.
815816
816817
When `chat-variant` is provided (the chat's persisted `:variant`) and is
817818
still supported by `full-model`, the broadcast keeps it; otherwise it
818819
falls back to the default agent's configured variant when valid, else
819820
nil. This lets resume preserve the variant the user last saw on the
820-
chat instead of resetting to the default agent's variant."
821+
chat instead of resetting to the default agent's variant.
822+
823+
When `chat-id` is provided, the broadcast is scoped to that chat and does
824+
not update the session-level config mirror."
821825
([full-model db* messenger config]
822-
(notify-selected-model-changed! full-model db* messenger config nil))
826+
(notify-selected-model-changed! full-model db* messenger config nil nil))
823827
([full-model db* messenger config chat-variant]
828+
(notify-selected-model-changed! full-model db* messenger config chat-variant nil))
829+
([full-model db* messenger config chat-variant chat-id]
824830
(when (and full-model (contains? (:models @db*) full-model))
825831
(let [default-agent-name (validate-agent-name
826832
(or (:defaultAgent (:chat config))
@@ -839,26 +845,38 @@
839845
select-variant (cond
840846
(valid? chat-variant) chat-variant
841847
(valid? agent-variant) agent-variant
842-
:else nil)]
843-
(notify-fields-changed-only!
844-
{:chat {:select-model full-model
845-
:variants (or variants [])
846-
:select-variant select-variant}}
847-
messenger
848-
db*)))))
848+
:else nil)
849+
selection {:model full-model
850+
:variants (or variants [])
851+
:variant select-variant}
852+
payload {:chat {:select-model (:model selection)
853+
:variants (:variants selection)
854+
:select-variant (:variant selection)}}]
855+
(if chat-id
856+
(notify-fields-changed-only! payload messenger db* chat-id)
857+
(notify-fields-changed-only! payload messenger db*))
858+
selection))))
849859

850860
(defn notify-selected-trust-changed!
851861
"Server-initiated equivalent of a client-side trust toggle: aligns the
852862
client-side trust indicator with the chat's persisted `:trust` value.
853863
Emits via `notify-fields-changed-only!`, so it is a no-op when nothing
854864
changed. Used by chat resume flows (`chat/open`, `/resume`) so the icon
855865
the client shows matches the auto-approval behavior the server is about
856-
to apply (#426)."
857-
[trust db* messenger]
858-
(notify-fields-changed-only!
859-
{:chat {:select-trust (boolean trust)}}
860-
messenger
861-
db*))
866+
to apply (#426).
867+
868+
When `chat-id` is provided, the broadcast is scoped to that chat and does
869+
not update the session-level config mirror. Returns the normalized boolean
870+
trust selection."
871+
([trust db* messenger]
872+
(notify-selected-trust-changed! trust db* messenger nil))
873+
([trust db* messenger chat-id]
874+
(let [selection (boolean trust)
875+
payload {:chat {:select-trust selection}}]
876+
(if chat-id
877+
(notify-fields-changed-only! payload messenger db* chat-id)
878+
(notify-fields-changed-only! payload messenger db*))
879+
selection)))
862880

863881
(def ^:private config-schema-url "https://eca.dev/config.json")
864882

src/eca/features/chat.clj

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,15 +2105,16 @@
21052105
"Replay a persisted chat over the wire so a freshly-started client can render
21062106
it. Emits `chat/cleared` (messages) followed by `chat/opened` and streams each
21072107
persisted message via `send-chat-contents!`. Also re-aligns the client's
2108-
selected model to the resumed chat's stored `:model` via a `config/updated`
2109-
notification, so an Opus-started chat keeps using Opus on the next prompt
2110-
(#417). Performs no DB mutation otherwise.
2108+
selected model to the resumed chat's stored `:model` via a scoped
2109+
`config/updated` notification and returns the restored model, agent, variant,
2110+
variants, and trust as an atomic `:selection` snapshot (#417). Performs no
2111+
DB mutation otherwise.
21112112
21122113
Optional `:limit`/`:before`/`:after` window the replay (same cursors as
21132114
`chat/history`); when provided, the response includes `:meta` so the client
21142115
can page older via `chat/history`. Without them the full history is replayed.
2115-
Returns `{:found? false}` when the chat does not exist or is a subagent,
2116-
otherwise `{:found? true :chat-id ... :title ... :meta? ...}`."
2116+
Returns `{:found false}` when the chat does not exist or is a subagent,
2117+
otherwise `{:found true :chat-id ... :title ... :selection ... :meta? ...}`."
21172118
[{:keys [chat-id limit before after] :as params} db* messenger config]
21182119
(let [chat (get-in @db* [:chats chat-id])
21192120
windowed? (some #(contains? params %) [:limit :before :after])
@@ -2122,10 +2123,10 @@
21222123
{:limit limit :before before :after after}))]
21232124
(cond
21242125
(or (nil? chat) (:subagent chat))
2125-
{:found? false}
2126+
{:found false}
21262127

21272128
(= :cursor-expired (:error page))
2128-
{:found? true :chat-id chat-id
2129+
{:found true :chat-id chat-id
21292130
:error {:code "cursor_expired"
21302131
:message "Cursor no longer points to an existing message; refetch the latest page"}}
21312132

@@ -2138,15 +2139,29 @@
21382139
(messenger/chat-opened messenger (assoc-some {:chat-id chat-id} :title title))
21392140
(send-chat-contents! messages chat-ctx)
21402141
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
2141-
(config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat))
2142-
(config/notify-selected-trust-changed! (:trust chat) db* messenger)
2143-
(assoc-some {:found? true :chat-id chat-id :title title}
2144-
:meta (when windowed?
2145-
{:total (:total page)
2146-
:returned (:returned page)
2147-
:before-cursor (:before-cursor page)
2148-
:after-cursor (:after-cursor page)
2149-
:compaction-cursor (history/compaction-cursor (:messages chat))}))))))
2142+
(let [model-selection (config/notify-selected-model-changed!
2143+
(:model chat) db* messenger config (:variant chat) chat-id)
2144+
trust-selection (config/notify-selected-trust-changed!
2145+
(:trust chat) db* messenger chat-id)
2146+
agent-selection (config/validate-agent-name
2147+
(or (:agent chat)
2148+
(:defaultAgent (:chat config))
2149+
(:defaultAgent config))
2150+
config)
2151+
selection (merge {:model nil :variant nil :variants []}
2152+
model-selection
2153+
{:agent agent-selection
2154+
:trust trust-selection})]
2155+
(assoc-some {:found true
2156+
:chat-id chat-id
2157+
:title title
2158+
:selection selection}
2159+
:meta (when windowed?
2160+
{:total (:total page)
2161+
:returned (:returned page)
2162+
:before-cursor (:before-cursor page)
2163+
:after-cursor (:after-cursor page)
2164+
:compaction-cursor (history/compaction-cursor (:messages chat))})))))))
21502165

21512166
(defn fetch-history
21522167
"Window a chat's persisted messages and return the transformed content items

src/eca/features/commands.clj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -752,11 +752,11 @@
752752
(db/update-workspaces-cache! @db* metrics)
753753
;; Align the client's selected model with the resumed chat
754754
;; so the LLM call keeps using the chat's original model. #417
755-
(config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat))
755+
(config/notify-selected-model-changed! (:model chat) db* messenger config (:variant chat) chat-id)
756756
;; Align the client's trust indicator with the resumed
757757
;; chat's persisted :trust so the icon matches the
758758
;; auto-approval behavior the server will apply. #426
759-
(config/notify-selected-trust-changed! (:trust chat) db* messenger)
759+
(config/notify-selected-trust-changed! (:trust chat) db* messenger chat-id)
760760
{:type :chat-messages
761761
:clear-before? true
762762
:chats {chat-id {:title (:title chat)
@@ -893,8 +893,8 @@
893893
(db/update-workspaces-cache! @db* metrics)
894894
(messenger/chat-opened messenger {:chat-id imported-id :title (:title imported-chat)})
895895
;; Align the client's model/trust with the imported chat (like /resume).
896-
(config/notify-selected-model-changed! (:model imported-chat) db* messenger config (:variant imported-chat))
897-
(config/notify-selected-trust-changed! (:trust imported-chat) db* messenger)
896+
(config/notify-selected-model-changed! (:model imported-chat) db* messenger config (:variant imported-chat) imported-id)
897+
(config/notify-selected-trust-changed! (:trust imported-chat) db* messenger imported-id)
898898
{:type :chat-messages
899899
:chats {imported-id {:title (:title imported-chat)
900900
:messages (concat [{:role "system" :content [{:type :text :text summary}]}]

src/eca/handlers.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,8 @@
282282
(defn chat-open
283283
"Replay a persisted chat over the wire for the client to render.
284284
Emits chat/cleared, chat/opened and per-message chat/contentReceived
285-
notifications for the target chat, plus a config/updated notification
286-
to restore the chat's stored model selection. Returns `{:found? bool ...}`."
285+
notifications for the target chat, plus scoped config/updated notifications
286+
to restore the chat's stored selection. Returns `{:found bool ...}`."
287287
[{:keys [db* messenger config metrics]} params]
288288
(metrics/task metrics :eca/chat-open
289289
(f.chat/open-chat! params db* messenger config)))

test/eca/config_test.clj

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,46 @@
852852
(h/db*) (h/messenger) (h/config)
853853
nil)
854854
(is (match? {:config-updated [{:chat {:select-variant "high"}}]}
855-
(h/messages)))))
855+
(h/messages))))
856+
857+
(testing "6-arity: chat-id scopes the model and variant without changing session defaults"
858+
(h/reset-components!)
859+
(h/config! {:providers {"anthropic" {:models {"claude-sonnet-4-5"
860+
{:variants {"low" {:a 1}
861+
"high" {:a 2}}}}}}
862+
:defaultAgent "code"
863+
:agent {"code" {:variant "high"}}})
864+
(swap! (h/db*) assoc
865+
:models {"anthropic/claude-sonnet-4-5" {:tools true}}
866+
:last-config-notified {:chat {:select-model "openai/gpt-5.2"
867+
:select-variant "medium"}})
868+
(let [session-defaults (:last-config-notified (h/db))
869+
selection (config/notify-selected-model-changed!
870+
"anthropic/claude-sonnet-4-5"
871+
(h/db*) (h/messenger) (h/config)
872+
"low" "chat-a")]
873+
(is (= {:model "anthropic/claude-sonnet-4-5"
874+
:variants ["high" "low"]
875+
:variant "low"}
876+
selection))
877+
(is (match? {:config-updated [{:chat-id "chat-a"
878+
:chat {:select-model "anthropic/claude-sonnet-4-5"
879+
:variants ["high" "low"]
880+
:select-variant "low"}}]}
881+
(h/messages)))
882+
(is (= session-defaults (:last-config-notified (h/db)))))))
883+
884+
(deftest notify-selected-trust-changed-test
885+
(testing "4-arity: chat-id scopes trust without changing session defaults"
886+
(h/reset-components!)
887+
(swap! (h/db*) assoc :last-config-notified {:chat {:select-trust false}})
888+
(let [session-defaults (:last-config-notified (h/db))
889+
selection (config/notify-selected-trust-changed!
890+
true (h/db*) (h/messenger) "chat-a")]
891+
(is (true? selection))
892+
(is (= [{:chat-id "chat-a" :chat {:select-trust true}}]
893+
(:config-updated (h/messages))))
894+
(is (= session-defaults (:last-config-notified (h/db)))))))
856895

857896
(deftest agent-modes-test
858897
(testing "scalar string :mode is honored"

0 commit comments

Comments
 (0)