Skip to content

Commit 64a4734

Browse files
ericdalloeca-agent
andcommitted
Per-chat scoping of chat/selectedModelChanged and chat/selectedAgentChanged
Both notifications now accept an optional chatId. When supplied AND valid AND the chat exists, the server persists model/variant/agent on that specific chat record (using a CAS swap so a concurrent chat/delete cannot resurrect a deleted chat as a ghost record) and emits a config/updated notification carrying the same chatId so clients scope the update to one chat. Invalid or unknown ids fall through to the legacy session-wide path with a warn log so we never announce per-chat state for a chat the server does not recognize. Also wires up Phase A from the same issue: chat/prompt now treats a client-supplied unknown chatId as a new empty chat (atomic CAS seed + chat/opened emission), with validation rejecting blank, the reserved subagent- prefix, embedded whitespace / control chars, and ids longer than 256 chars. tool-call-{approve,reject} now no-op safely on unknown chats. The persisted per-chat key for the agent is now :agent (was :selected-agent) for symmetry with :model / :variant. The remote REST endpoints /chat/:chatId/select-{model,agent,variant} now forward the URL chat-id to the underlying handlers. editor-code-assistant/eca-emacs#231 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent 642d7f5 commit 64a4734

8 files changed

Lines changed: 595 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Support client-generated chat ids: clients may now create the `chatId` themselves and send it on the first `chat/prompt`. eca-emacs#231.
6+
- Per-chat scoping of model/agent changes: `chat/selectedModelChanged` and `chat/selectedAgentChanged` now accept an optional `chatId`; when provided the server scopes the persisted state and the resulting `config/updated` broadcast to that chat. The remote REST endpoints `/chat/:chatId/select-model|select-agent|select-variant` now forward the URL chat-id to those handlers (previously dropped). `validate-client-chat-id` is now public and rejects whitespace / control chars. eca-emacs#231.
7+
58
## 0.133.0
69

710
- Add `chat/promptSteerRemove` notification for discarding a pending steer message before it is consumed at the next LLM turn boundary. Idempotent: silent no-op when no steer is pending.

docs/protocol.md

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,22 @@ _Request:_
366366
```typescript
367367
interface ChatPromptParams {
368368
/**
369-
* The chat session identifier. If not provided, a new chat session will be created.
369+
* The chat session identifier.
370+
*
371+
* Clients should generate this id (e.g. a UUID) at the moment the user
372+
* opens a new chat in the UI and reuse it across all subsequent requests
373+
* for that chat. If the server has never seen the id, it materializes a
374+
* new empty chat record on demand and emits a `chat/opened` notification
375+
* so other observers (additional clients, remote viewers) can sync.
376+
*
377+
* Backward compatibility: clients that omit this field still work — the
378+
* server will mint an id and return it in the response. In that legacy
379+
* path no `chat/opened` notification is emitted (the prompting client
380+
* already learns the id from the response payload).
381+
*
382+
* Reserved: ids starting with `subagent-` are reserved for server-managed
383+
* subagent chats and will be rejected. Ids must be non-blank and at most
384+
* 256 characters.
370385
*/
371386
chatId?: string;
372387

@@ -1740,8 +1755,18 @@ interface ChatClearedParams {
17401755

17411756
### Chat opened (⬅️)
17421757

1743-
A server notification indicating a new chat was created server-side (e.g. via `/fork`).
1744-
Clients should create a new chat entry in the UI. The chat messages will follow as `chat/contentReceived` notifications.
1758+
A server notification indicating that a chat exists or was just materialized
1759+
server-side. Emitted when:
1760+
1761+
- a chat is forked via `/fork` or the `chat/fork` request,
1762+
- a persisted chat is replayed via `chat/open`,
1763+
- a client-supplied `chatId` is seen for the first time on `chat/prompt`
1764+
(the legacy null-`chatId` path does not emit this).
1765+
1766+
Clients should treat this notification as **idempotent**: if the `chatId`
1767+
already exists in the client's UI, no new chat entry should be created — the
1768+
notification should be ignored or used to update the title. Chat messages
1769+
follow as `chat/contentReceived` notifications.
17451770

17461771
_Notification:_
17471772

@@ -1752,17 +1777,70 @@ _Notification:_
17521777
interface ChatOpenedParams {
17531778

17541779
/**
1755-
* The new chat session identifier.
1780+
* The chat session identifier.
17561781
*/
17571782
chatId: string;
17581783

17591784
/**
1760-
* The title of the new chat.
1785+
* The title of the chat. Absent for client-initiated new chats that do
1786+
* not yet have a title.
17611787
*/
17621788
title?: string;
17631789
}
17641790
```
17651791

1792+
### Chat status changed (⬅️)
1793+
1794+
A server notification carrying lifecycle transitions for a chat. Clients
1795+
typically use it to drive a "chat is running/idle/stopping" indicator.
1796+
1797+
_Notification:_
1798+
1799+
* method: `chat/statusChanged`
1800+
* params: `ChatStatusChangedParams` defined as follows:
1801+
1802+
```typescript
1803+
interface ChatStatusChangedParams {
1804+
1805+
/**
1806+
* The chat session identifier.
1807+
*/
1808+
chatId: string;
1809+
1810+
/**
1811+
* Lifecycle status of the chat.
1812+
*
1813+
* - `running`: a prompt is actively being processed (LLM streaming, tool calls in flight).
1814+
* - `idle`: prompt finished; chat is ready for further input.
1815+
* - `stopping`: a stop has been requested (via `chat/promptStop`) and the
1816+
* chat is winding down its in-flight work.
1817+
*/
1818+
status: 'running' | 'idle' | 'stopping';
1819+
}
1820+
```
1821+
1822+
### Chat deleted (⬅️)
1823+
1824+
A server notification confirming that a chat was deleted (either via the
1825+
`chat/delete` request from this client or via another connected client / a
1826+
server-side cleanup such as `chatRetentionDays`). Clients should remove the
1827+
chat from their UI.
1828+
1829+
_Notification:_
1830+
1831+
* method: `chat/deleted`
1832+
* params: `ChatDeletedParams` defined as follows:
1833+
1834+
```typescript
1835+
interface ChatDeletedParams {
1836+
1837+
/**
1838+
* The chat session identifier that was deleted.
1839+
*/
1840+
chatId: string;
1841+
}
1842+
```
1843+
17661844
### Chat delete (↩️)
17671845

17681846
A client request to delete a existing chat, removing all previous messages and used tokens/costs from memory, good for reduce context or start a new clean chat.
@@ -1943,6 +2021,16 @@ _Notification:_
19432021

19442022
```typescript
19452023
interface ChatSelectedAgentChanged {
2024+
/**
2025+
* The chat session identifier the change applies to.
2026+
*
2027+
* When provided, the server persists the selection on this specific
2028+
* chat record and the resulting `config/updated` broadcast carries the
2029+
* same `chatId` so clients scope the update to one chat. When omitted,
2030+
* the change is treated as session-wide (legacy behavior).
2031+
*/
2032+
chatId?: string;
2033+
19462034
/**
19472035
* The selected agent.
19482036
*/
@@ -1965,6 +2053,16 @@ _Notification:_
19652053

19662054
```typescript
19672055
interface ChatSelectedModelChanged {
2056+
/**
2057+
* The chat session identifier the change applies to.
2058+
*
2059+
* When provided, the server persists model + variant on this specific
2060+
* chat record and the resulting `config/updated` broadcast carries the
2061+
* same `chatId` so clients scope the update to one chat. When omitted,
2062+
* the change is treated as session-wide (legacy behavior).
2063+
*/
2064+
chatId?: string;
2065+
19682066
/**
19692067
* The selected model (full model name, e.g. "anthropic/claude-sonnet-4-5").
19702068
*/
@@ -2322,6 +2420,17 @@ _Notification:_
23222420

23232421
```typescript
23242422
interface ConfigUpdatedParams {
2423+
/**
2424+
* When present, scopes this update to a single chat: clients should
2425+
* apply the per-chat fields (`selectModel`, `selectAgent`,
2426+
* `selectVariant`, `selectTrust`) only to the chat with this id and
2427+
* leave other chats untouched. When absent, the update is session-wide
2428+
* and clients should apply the per-chat fields to every chat (legacy
2429+
* behavior, used for the initial config push and other genuinely
2430+
* session-wide events).
2431+
*/
2432+
chatId?: string;
2433+
23252434
/**
23262435
* Configs related to chat.
23272436
*/

src/eca/config.clj

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -665,12 +665,25 @@
665665
(when (seq result)
666666
result))))
667667

668-
(defn notify-fields-changed-only! [config-updated messenger db*]
669-
(let [config-to-notify (diff-keeping-vectors (:last-config-notified @db*)
670-
config-updated)]
671-
(when (seq config-to-notify)
672-
(swap! db* update :last-config-notified shared/deep-merge config-to-notify)
673-
(messenger/config-updated messenger config-to-notify))))
668+
(defn notify-fields-changed-only!
669+
"Emit `config/updated` with the fields that have changed against the
670+
session-level mirror (`:last-config-notified`) and update that mirror.
671+
672+
When called with `chat-id` (4-arity), the broadcast is scoped to that
673+
chat: it includes `:chat-id` in the payload so the client can apply
674+
the change only to that chat's UI state, and bypasses the session-
675+
level mirror diff (so per-chat changes never collapse against the
676+
session mirror or each other). Used by `chat/selectedModelChanged`
677+
and `chat/selectedAgentChanged` when the client supplies a `chatId`."
678+
([config-updated messenger db*]
679+
(let [config-to-notify (diff-keeping-vectors (:last-config-notified @db*)
680+
config-updated)]
681+
(when (seq config-to-notify)
682+
(swap! db* update :last-config-notified shared/deep-merge config-to-notify)
683+
(messenger/config-updated messenger config-to-notify))))
684+
([config-updated messenger _db* chat-id]
685+
(when chat-id
686+
(messenger/config-updated messenger (assoc config-updated :chat-id chat-id)))))
674687

675688
(defn notify-selected-model-changed!
676689
"Server-initiated equivalent of a client `chat/selectedModelChanged`: aligns

src/eca/features/chat.clj

Lines changed: 106 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,64 +1244,118 @@
12441244
:model full-model
12451245
:status :prompting}))
12461246

1247+
(def ^:private max-client-chat-id-length 256)
1248+
1249+
(defn validate-client-chat-id
1250+
"Validates a client-supplied chat id. Returns nil when valid, otherwise an
1251+
error message string.
1252+
1253+
Public so non-prompt selection handlers (`chat/selectedModelChanged`,
1254+
`chat/selectedAgentChanged`) can apply the same rules to their `chat-id`
1255+
field.
1256+
1257+
Rejected: blank, the reserved `subagent-` prefix (used for deterministic
1258+
subagent chat ids in `eca.features.tools.agent`), embedded whitespace or
1259+
control characters (which would mangle log lines / tab-line titles), and
1260+
ids longer than `max-client-chat-id-length` characters."
1261+
[chat-id]
1262+
(cond
1263+
(string/blank? chat-id)
1264+
"chatId must be a non-blank string"
1265+
1266+
(string/starts-with? chat-id "subagent-")
1267+
"chatId prefix 'subagent-' is reserved for server-managed subagent chats"
1268+
1269+
(re-find #"[\s\p{Cntrl}]" chat-id)
1270+
"chatId must not contain whitespace or control characters"
1271+
1272+
(> (count chat-id) max-client-chat-id-length)
1273+
(str "chatId must be " max-client-chat-id-length " characters or fewer")))
1274+
12471275
(defn prompt
12481276
[{:keys [message agent behavior chat-id contexts variant trust] :as params} db* messenger config metrics]
1249-
(let [raw-agent (or agent
1250-
behavior ;; backward compat: accept old 'behavior' param
1251-
(-> config :chat :defaultAgent) ;; legacy
1252-
(-> config :defaultAgent))
1253-
chat-id (or chat-id
1254-
(let [new-id (str (random-uuid))]
1255-
(swap! db* assoc-in [:chats new-id] {:id new-id})
1256-
new-id))
1257-
selected-agent (config/validate-agent-name raw-agent config)
1258-
agent-config (get-in config [:agent selected-agent])
1259-
base-chat-ctx (assoc-some {:metrics metrics
1260-
:config config
1261-
:contexts contexts
1262-
:db* db*
1263-
:messenger messenger
1264-
:user-content-id (lifecycle/new-content-id)
1265-
:message (string/trim message)
1266-
:chat-id chat-id
1267-
:agent selected-agent
1268-
:agent-config agent-config
1269-
:trust trust
1270-
:variant (or variant (:variant agent-config))}
1271-
:parent-chat-id (get-in @db* [:chats chat-id :parent-chat-id]))
1272-
_ (when (some? trust)
1273-
(swap! db* assoc-in [:chats chat-id :trust] trust))]
1274-
(try
1275-
(prompt* params base-chat-ctx)
1276-
(catch Exception e
1277-
(logger/error e)
1278-
(lifecycle/send-content! base-chat-ctx :system {:type :text
1279-
:text (str "Error: " (ex-message e) "\n\nCheck ECA stderr for more details.")})
1280-
(lifecycle/finish-chat-prompt! :idle (dissoc base-chat-ctx :on-finished-side-effect))
1281-
{:chat-id chat-id
1282-
:model "error"
1283-
:status :error}))))
1277+
(let [provided-chat-id chat-id
1278+
invalid-id-reason (when (some? provided-chat-id)
1279+
(validate-client-chat-id provided-chat-id))]
1280+
(if invalid-id-reason
1281+
(do (logger/warn logger-tag "Rejected chat/prompt with invalid chat-id"
1282+
{:chat-id provided-chat-id :reason invalid-id-reason})
1283+
{:chat-id provided-chat-id
1284+
:model "error"
1285+
:status :error})
1286+
(let [raw-agent (or agent
1287+
behavior ;; backward compat: accept old 'behavior' param
1288+
(-> config :chat :defaultAgent) ;; legacy
1289+
(-> config :defaultAgent))
1290+
chat-id (or provided-chat-id (str (random-uuid)))
1291+
;; Atomically seed the chat record if absent and remember whether
1292+
;; we were the ones to create it. swap-vals! returns [old new] so
1293+
;; chat-just-created? is true iff the chat was missing pre-swap.
1294+
[old-db _] (swap-vals! db* update :chats
1295+
(fn [chats]
1296+
(if (contains? chats chat-id)
1297+
chats
1298+
(assoc chats chat-id {:id chat-id}))))
1299+
chat-just-created? (not (contains? (:chats old-db) chat-id))
1300+
;; Notify observers (other clients, remote SSE viewers) about a
1301+
;; new client-initiated chat. Skipped on the legacy null-id path
1302+
;; because the prompting client learns its id from the response.
1303+
_ (when (and provided-chat-id chat-just-created?)
1304+
(messenger/chat-opened messenger {:chat-id chat-id}))
1305+
selected-agent (config/validate-agent-name raw-agent config)
1306+
agent-config (get-in config [:agent selected-agent])
1307+
base-chat-ctx (assoc-some {:metrics metrics
1308+
:config config
1309+
:contexts contexts
1310+
:db* db*
1311+
:messenger messenger
1312+
:user-content-id (lifecycle/new-content-id)
1313+
:message (string/trim message)
1314+
:chat-id chat-id
1315+
:agent selected-agent
1316+
:agent-config agent-config
1317+
:trust trust
1318+
:variant (or variant (:variant agent-config))}
1319+
:parent-chat-id (get-in @db* [:chats chat-id :parent-chat-id]))
1320+
_ (when (some? trust)
1321+
(swap! db* assoc-in [:chats chat-id :trust] trust))]
1322+
(try
1323+
(prompt* params base-chat-ctx)
1324+
(catch Exception e
1325+
(logger/error e)
1326+
(lifecycle/send-content! base-chat-ctx :system {:type :text
1327+
:text (str "Error: " (ex-message e) "\n\nCheck ECA stderr for more details.")})
1328+
(lifecycle/finish-chat-prompt! :idle (dissoc base-chat-ctx :on-finished-side-effect))
1329+
{:chat-id chat-id
1330+
:model "error"
1331+
:status :error}))))))
12841332

12851333
(defn tool-call-approve [{:keys [chat-id tool-call-id save]} db* messenger metrics]
1286-
(let [chat-ctx {:chat-id chat-id
1287-
:db* db*
1288-
:metrics metrics
1289-
:messenger messenger}]
1290-
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-approve
1291-
{:reason {:code :user-choice-allow
1292-
:text "Tool call allowed by user choice"}})
1293-
(when (= "session" save)
1294-
(let [tool-call-name (get-in @db* [:chats chat-id :tool-calls tool-call-id :name])]
1295-
(swap! db* assoc-in [:tool-calls tool-call-name :remember-to-approve?] true)))))
1334+
(if-not (get-in @db* [:chats chat-id :tool-calls tool-call-id])
1335+
(logger/warn logger-tag "tool-call-approve ignored: unknown chat or tool-call"
1336+
{:chat-id chat-id :tool-call-id tool-call-id})
1337+
(let [chat-ctx {:chat-id chat-id
1338+
:db* db*
1339+
:metrics metrics
1340+
:messenger messenger}]
1341+
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-approve
1342+
{:reason {:code :user-choice-allow
1343+
:text "Tool call allowed by user choice"}})
1344+
(when (= "session" save)
1345+
(let [tool-call-name (get-in @db* [:chats chat-id :tool-calls tool-call-id :name])]
1346+
(swap! db* assoc-in [:tool-calls tool-call-name :remember-to-approve?] true))))))
12961347

12971348
(defn tool-call-reject [{:keys [chat-id tool-call-id]} db* messenger metrics]
1298-
(let [chat-ctx {:chat-id chat-id
1299-
:db* db*
1300-
:metrics metrics
1301-
:messenger messenger}]
1302-
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-reject
1303-
{:reason {:code :user-choice-deny
1304-
:text "Tool call rejected by user choice"}})))
1349+
(if-not (get-in @db* [:chats chat-id :tool-calls tool-call-id])
1350+
(logger/warn logger-tag "tool-call-reject ignored: unknown chat or tool-call"
1351+
{:chat-id chat-id :tool-call-id tool-call-id})
1352+
(let [chat-ctx {:chat-id chat-id
1353+
:db* db*
1354+
:metrics metrics
1355+
:messenger messenger}]
1356+
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-reject
1357+
{:reason {:code :user-choice-deny
1358+
:text "Tool call rejected by user choice"}}))))
13051359

13061360
(defn query-context
13071361
[{:keys [query contexts chat-id]}

0 commit comments

Comments
 (0)