Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 26 additions & 10 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions integration-test/entrypoint.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 8 additions & 1 deletion integration-test/integration/chat/list_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 36 additions & 18 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -839,26 +845,38 @@
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
client-side trust indicator with the chat's persisted `:trust` value.
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")

Expand Down
47 changes: 31 additions & 16 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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"}}

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}]}]
Expand Down
4 changes: 2 additions & 2 deletions src/eca/handlers.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
41 changes: 40 additions & 1 deletion test/eca/config_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading