Skip to content

Commit 03d3b36

Browse files
authored
Merge branch 'master' into fix/codex-rate-limit-reset
2 parents 2dfd8d1 + d3e901f commit 03d3b36

18 files changed

Lines changed: 662 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
## Unreleased
44

5-
- Fix ChatGPT/Codex OAuth rate-limit retries using the response reset instead of quota snapshot headers; cap waits at 60 seconds by default.
5+
- 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`.
10+
- Fix ChatGPT/Codex OAuth rate-limit retries using the response reset instead of quota snapshot headers; cap waits at 60 seconds by default.
811

912
## 0.147.0
1013

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)))

src/eca/llm_api.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,7 @@
466466
(catch Exception e
467467
(logger/warn logger-tag "on-history-sanitized callback failed" {:exception (ex-message e)})))))
468468
emit-first-message-fn (fn [& args]
469-
(when-not @first-response-received*
470-
(reset! first-response-received* true)
469+
(when (compare-and-set! first-response-received* false true)
471470
(apply on-first-response-received args)))
472471
on-message-received-wrapper (fn [& args]
473472
(apply emit-first-message-fn args)
@@ -523,6 +522,7 @@
523522
(if (and (contains? #{:rate-limited :overloaded :retryable-custom :premature-stop} error-type)
524523
(< attempt max-retries)
525524
(not wait-too-long?)
525+
(not @first-response-received*)
526526
(not (cancelled?)))
527527
(let [delay-ms (or rate-limit-delay-ms
528528
(retry-delay-ms attempt))]

src/eca/llm_providers/errors.clj

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
#"(?i)connection refused"
3838
#"(?i)UnresolvedAddressException"])
3939

40+
(def ^:private openai-transient-message-pattern
41+
#"(?i)an error occurred while processing your request\.\s+you can retry your request\b")
42+
4043
(defn ^:private matches-any-pattern? [^String text patterns]
4144
(when text
4245
(some #(re-find % text) patterns)))
@@ -66,6 +69,25 @@
6669

6770
:else nil))
6871

72+
(defn ^:private classify-openai-responses-error
73+
[{:keys [code type message] source :error/source}]
74+
(when (= :openai-responses source)
75+
(cond
76+
(some #{"server_error"} [code type])
77+
{:error/type :overloaded}
78+
79+
(some #{"rate_limit_exceeded"} [code type])
80+
{:error/type :rate-limited}
81+
82+
(or (some? code) (some? type))
83+
{:error/type :unknown}
84+
85+
(and (string? message)
86+
(re-find openai-transient-message-pattern message))
87+
{:error/type :overloaded}
88+
89+
:else nil)))
90+
6991
(defn ^:private classify-by-message
7092
"Fallback classification from unstructured error message strings
7193
(e.g. SSE stream errors where HTTP status is not available)."
@@ -113,7 +135,8 @@
113135
(defn classify-error
114136
"Classifies an error map into a semantic error type.
115137
116-
Accepts the standard on-error map shape: {:message :status :body :exception}.
138+
Accepts the standard on-error map shape: {:message :status :body :exception},
139+
plus optional structured provider fields such as :code, :type, and :error/source.
117140
Optional `retry-rules` seq of user-configured rules checked before built-in classification.
118141
Returns a map with :error/type — one of:
119142
:retryable-custom — matched a user-configured retry rule (with optional :error/label)
@@ -127,6 +150,7 @@
127150
(or (when-let [pre-type (:error/type error-data)]
128151
{:error/type pre-type})
129152
(classify-by-custom-rules error-data retry-rules)
153+
(classify-openai-responses-error error-data)
130154
(when status
131155
(classify-by-status-and-body error-data))
132156
(classify-by-message error-data)

0 commit comments

Comments
 (0)