Skip to content

Commit 6455e3e

Browse files
committed
Support chat/list and chat/open for eca-desktop
1 parent e2b7cc6 commit 6455e3e

7 files changed

Lines changed: 341 additions & 1 deletion

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+
- New `chat/list` JSON-RPC request returning a summary list of persisted chats for the current workspace (id, title, status, createdAt, updatedAt, model, messageCount). Supports optional `limit` and `sortBy` params. Lets clients populate a chat sidebar on startup without requiring the user to resume each chat manually.
6+
- New `chat/open` JSON-RPC request that replays a persisted chat to the client by emitting `chat/cleared` (messages), `chat/opened` and the full sequence of `chat/contentReceived` notifications without mutating server state. Intended to be paired with `chat/list` to render a chat the user has not opened in the current client session.
7+
58
## 0.127.1
69

710
- Auto allow ask_user tool by default.

docs/protocol.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,108 @@ _Response:_
17561756
interface ChatUpdateResponse {}
17571757
```
17581758

1759+
### Chat list (↩️)
1760+
1761+
A client request to list all persisted chats for the current workspace,
1762+
so a freshly-started client can populate its sidebar without waiting for the
1763+
user to resume or re-create each chat.
1764+
1765+
Subagent chats are excluded. Results are sorted descending by `updatedAt` by
1766+
default, falling back to `createdAt` when `updatedAt` is missing.
1767+
1768+
_Request:_
1769+
1770+
* method: `chat/list`
1771+
* params: `ChatListParams` defined as follows:
1772+
1773+
```typescript
1774+
interface ChatListParams {
1775+
/**
1776+
* Optional maximum number of chats to return.
1777+
* When omitted or non-positive, all chats are returned.
1778+
*/
1779+
limit?: number;
1780+
1781+
/**
1782+
* Optional sort key. Defaults to "updatedAt".
1783+
*/
1784+
sortBy?: 'updatedAt' | 'createdAt';
1785+
}
1786+
```
1787+
1788+
_Response:_
1789+
1790+
```typescript
1791+
interface ChatListResponse {
1792+
chats: ChatSummary[];
1793+
}
1794+
1795+
interface ChatSummary {
1796+
/** The chat session identifier. */
1797+
id: string;
1798+
1799+
/** Human-readable title, when available. */
1800+
title?: string;
1801+
1802+
/** Current chat status. */
1803+
status: 'idle' | 'running' | 'stopping' | 'login';
1804+
1805+
/** Epoch millis when the chat was created, when available. */
1806+
createdAt?: number;
1807+
1808+
/** Epoch millis of the most recent update, when available. */
1809+
updatedAt?: number;
1810+
1811+
/** The last full model id used for this chat, when available. */
1812+
model?: string;
1813+
1814+
/** Number of persisted messages. */
1815+
messageCount: number;
1816+
}
1817+
```
1818+
1819+
### Chat open (↩️)
1820+
1821+
A client request to hydrate a previously-persisted chat so it can be rendered
1822+
in the UI. The server replays the chat by emitting `chat/cleared` (messages),
1823+
`chat/opened`, and a sequence of `chat/contentReceived` notifications matching
1824+
the persisted messages — without mutating server state. Typically used after
1825+
`chat/list` when the user selects a chat that has not been opened in the current
1826+
client session.
1827+
1828+
_Request:_
1829+
1830+
* method: `chat/open`
1831+
* params: `ChatOpenParams` defined as follows:
1832+
1833+
```typescript
1834+
interface ChatOpenParams {
1835+
/**
1836+
* The chat session identifier to open.
1837+
*/
1838+
chatId: string;
1839+
}
1840+
```
1841+
1842+
_Response:_
1843+
1844+
```typescript
1845+
interface ChatOpenResponse {
1846+
/**
1847+
* True when the chat exists and its content has been replayed via
1848+
* chat/cleared + chat/opened + chat/contentReceived. False when the
1849+
* chat is unknown or is a subagent chat.
1850+
*/
1851+
found: boolean;
1852+
1853+
/** The chat id that was replayed (echo of the request), present when found. */
1854+
chatId?: string;
1855+
1856+
/** The chat title at the time of replay, when available. */
1857+
title?: string;
1858+
}
1859+
```
1860+
17591861
### Chat selected agent changed (➡️)
17601862

17611863
A client notification for server telling the user selected a different agent in chat.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
(ns integration.chat.list-test
2+
"Exercises the `chat/list` and `chat/open` JSON-RPC methods end-to-end: creates
3+
a chat via a simple prompt, asks the server for the current chat list, then
4+
replays it via `chat/open` and asserts the server emits the expected
5+
`chat/cleared` + `chat/opened` + `chat/contentReceived` notifications."
6+
(:require
7+
[clojure.test :refer [deftest is testing]]
8+
[integration.eca :as eca]
9+
[integration.fixture :as fixture]
10+
[llm-mock.mocks :as llm.mocks]
11+
[matcher-combinators.matchers :as m]
12+
[matcher-combinators.test :refer [match?]]))
13+
14+
(eca/clean-after-test)
15+
16+
(defn- drain-until-progress-finished!
17+
"Drain chat/contentReceived notifications for `chat-id` until we observe the
18+
finishing progress event. Used to wait for a prompt to settle."
19+
[chat-id]
20+
(loop []
21+
(let [n (eca/client-awaits-server-notification :chat/contentReceived)]
22+
(when-not (and (= chat-id (:chatId n))
23+
(= "system" (:role n))
24+
(= "progress" (get-in n [:content :type]))
25+
(= "finished" (get-in n [:content :state])))
26+
(recur)))))
27+
28+
(deftest chat-list-and-open-test
29+
(testing "chat/list returns summaries and chat/open replays the chat"
30+
(eca/start-process!)
31+
32+
(llm.mocks/set-case! :simple-text-0)
33+
34+
(eca/request! (fixture/initialize-request))
35+
(eca/notify! (fixture/initialized-notification))
36+
37+
(let [prompt-resp (eca/request! (fixture/chat-prompt-request
38+
{:model "openai/gpt-4.1"
39+
:message "Hello"}))
40+
chat-id (:chatId prompt-resp)]
41+
(is (string? chat-id))
42+
(drain-until-progress-finished! chat-id)
43+
44+
(testing "chat/list includes the new chat"
45+
(let [result (eca/request! [:chat/list {}])]
46+
(is (match? {:chats (m/embeds
47+
[{:id chat-id
48+
:status "idle"
49+
:messageCount (m/pred pos-int?)}])}
50+
result))))
51+
52+
(testing "chat/list honours :limit"
53+
(let [result (eca/request! [:chat/list {:limit 1}])]
54+
(is (= 1 (count (:chats result))))))
55+
56+
(testing "chat/open replays chat/cleared + chat/opened + contentReceived"
57+
(let [open-resp (eca/request! [:chat/open {:chat-id chat-id}])
58+
cleared (eca/client-awaits-server-notification :chat/cleared)
59+
opened (eca/client-awaits-server-notification :chat/opened)]
60+
(is (match? {:found true :chatId chat-id} open-resp))
61+
(is (match? {:chatId chat-id :messages true} cleared))
62+
(is (match? {:chatId chat-id} opened))
63+
;; At least one content-received notification must follow
64+
(is (match? {:chatId chat-id}
65+
(eca/client-awaits-server-notification :chat/contentReceived)))))
66+
67+
(testing "chat/open returns found=false for unknown chat ids"
68+
(let [result (eca/request! [:chat/open {:chat-id "does-not-exist"}])]
69+
(is (match? {:found false} result)))))))

src/eca/features/chat.clj

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,4 +1337,48 @@
13371337
(lifecycle/send-content! {:messenger messenger :chat-id chat-id}
13381338
:system
13391339
{:type :text :text (str "Chat forked to: " new-title)})))
1340-
{}))
1340+
{}))
1341+
1342+
(defn list-chats
1343+
"Pure projection over `(:chats db)`: returns a summary list intended for the
1344+
client sidebar. Subagent chats are excluded. Supports optional
1345+
`:limit` (positive int) and `:sort-by` (`:updated-at` or `:created-at`;
1346+
default `:updated-at`). Results are sorted descending by the chosen
1347+
timestamp, falling back to the other when the primary is nil."
1348+
[db {:keys [limit] sort-key :sort-by}]
1349+
(let [primary (or sort-key :updated-at)
1350+
secondary (if (= primary :updated-at) :created-at :updated-at)
1351+
chats (->> (vals (:chats db))
1352+
(remove :subagent)
1353+
(sort-by (fn [c] (or (get c primary) (get c secondary) 0)) >)
1354+
(mapv (fn [{:keys [id title status created-at updated-at model messages]}]
1355+
(shared/assoc-some
1356+
{:id id
1357+
:title title
1358+
:status (or status :idle)
1359+
:message-count (count messages)}
1360+
:created-at created-at
1361+
:updated-at updated-at
1362+
:model model))))]
1363+
{:chats (if (and limit (pos? (long limit)))
1364+
(vec (take (long limit) chats))
1365+
chats)}))
1366+
1367+
(defn open-chat!
1368+
"Replay a persisted chat over the wire so a freshly-started client can render
1369+
it. Emits `chat/cleared` (messages) followed by `chat/opened` and streams each
1370+
persisted message via `send-chat-contents!`. Performs no DB mutation.
1371+
Returns `{:found? false}` when the chat does not exist or is a subagent,
1372+
otherwise `{:found? true :chat-id ... :title ...}`."
1373+
[{:keys [chat-id]} db* messenger]
1374+
(let [chat (get-in @db* [:chats chat-id])]
1375+
(if (or (nil? chat) (:subagent chat))
1376+
{:found? false}
1377+
(let [title (:title chat)
1378+
messages (:messages chat)
1379+
chat-ctx {:chat-id chat-id :db* db* :messenger messenger}]
1380+
(messenger/chat-cleared messenger {:chat-id chat-id :messages true})
1381+
(messenger/chat-opened messenger (assoc-some {:chat-id chat-id} :title title))
1382+
(send-chat-contents! messages chat-ctx)
1383+
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
1384+
{:found? true :chat-id chat-id :title title}))))

src/eca/handlers.clj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,21 @@
244244
(metrics/task metrics :eca/chat-update
245245
(f.chat/update-chat params db* messenger metrics)))
246246

247+
(defn chat-list
248+
"Return a summary list of persisted chats for the current DB.
249+
Supports optional :limit and :sort-by params (see `f.chat/list-chats`)."
250+
[{:keys [db* metrics]} params]
251+
(metrics/task metrics :eca/chat-list
252+
(f.chat/list-chats @db* params)))
253+
254+
(defn chat-open
255+
"Replay a persisted chat over the wire for the client to render.
256+
Emits chat/cleared, chat/opened and per-message chat/contentReceived
257+
notifications for the target chat. Returns `{:found? bool ...}`."
258+
[{:keys [db* messenger metrics]} params]
259+
(metrics/task metrics :eca/chat-open
260+
(f.chat/open-chat! params db* messenger)))
261+
247262
(defn mcp-stop-server [{:keys [db* messenger metrics config]} params]
248263
(metrics/task metrics :eca/mcp-stop-server
249264
(f.tools/stop-server! (:name params) db* messenger config metrics)))

src/eca/server.clj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@
118118
(defmethod jsonrpc.server/receive-request "chat/update" [_ components params]
119119
(eventually (handlers/chat-update (with-config components) params)))
120120

121+
(defmethod jsonrpc.server/receive-request "chat/list" [_ components params]
122+
(eventually (handlers/chat-list (with-config components) params)))
123+
124+
(defmethod jsonrpc.server/receive-request "chat/open" [_ components params]
125+
(eventually (handlers/chat-open (with-config components) params)))
126+
121127
(defmethod jsonrpc.server/receive-notification "mcp/stopServer" [_ components params]
122128
(async-notify (handlers/mcp-stop-server (with-config components) params)))
123129

test/eca/handlers_test.clj

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
[eca.handlers :as handlers]
88
[eca.models :as models]
99
[eca.test-helper :as h]
10+
[matcher-combinators.matchers :as m]
1011
[matcher-combinators.test :refer [match?]]))
1112

1213
(h/reset-components-before-test)
@@ -259,3 +260,103 @@
259260
(is (match? {:config-updated [{:chat {:variants ["high" "medium"]
260261
:select-variant "high"}}]}
261262
(h/messages)))))
263+
264+
(defn ^:private seed-chats!
265+
"Seed the test db with a map of chats keyed by id."
266+
[chats]
267+
(swap! (h/db*) assoc :chats chats))
268+
269+
(deftest chat-list-test
270+
(testing "Returns empty list when db has no chats"
271+
(h/reset-components!)
272+
(is (match? {:chats []}
273+
(handlers/chat-list (h/components) {}))))
274+
275+
(testing "Returns summary of all non-subagent chats"
276+
(h/reset-components!)
277+
(seed-chats!
278+
{"a" {:id "a" :title "First" :status :idle
279+
:created-at 100 :updated-at 300 :model "anthropic/claude"
280+
:messages [{:role "user" :content "hi"}
281+
{:role "assistant" :content "hello"}]}
282+
"b" {:id "b" :title "Second" :status :idle
283+
:created-at 200 :updated-at 400
284+
:messages []}})
285+
(is (match? {:chats (m/in-any-order
286+
[{:id "b" :title "Second" :status :idle
287+
:created-at 200 :updated-at 400 :message-count 0}
288+
{:id "a" :title "First" :status :idle
289+
:created-at 100 :updated-at 300
290+
:model "anthropic/claude" :message-count 2}])}
291+
(handlers/chat-list (h/components) {}))))
292+
293+
(testing "Subagent chats are excluded"
294+
(h/reset-components!)
295+
(seed-chats!
296+
{"visible" {:id "visible" :title "Normal" :status :idle
297+
:created-at 100 :updated-at 100 :messages []}
298+
"hidden" {:id "hidden" :title "Sub" :status :idle
299+
:created-at 200 :updated-at 200 :messages []
300+
:subagent true :parent-chat-id "visible"}})
301+
(let [{:keys [chats]} (handlers/chat-list (h/components) {})]
302+
(is (= 1 (count chats)))
303+
(is (= "visible" (:id (first chats))))))
304+
305+
(testing "Sorted by :updated-at descending by default"
306+
(h/reset-components!)
307+
(seed-chats!
308+
{"old" {:id "old" :status :idle :created-at 10 :updated-at 100 :messages []}
309+
"mid" {:id "mid" :status :idle :created-at 20 :updated-at 200 :messages []}
310+
"new" {:id "new" :status :idle :created-at 30 :updated-at 300 :messages []}})
311+
(is (= ["new" "mid" "old"]
312+
(mapv :id (:chats (handlers/chat-list (h/components) {}))))))
313+
314+
(testing "`limit` caps the number of returned chats after sorting"
315+
(h/reset-components!)
316+
(seed-chats!
317+
{"a" {:id "a" :status :idle :created-at 10 :updated-at 100 :messages []}
318+
"b" {:id "b" :status :idle :created-at 20 :updated-at 200 :messages []}
319+
"c" {:id "c" :status :idle :created-at 30 :updated-at 300 :messages []}})
320+
(is (= ["c" "b"]
321+
(mapv :id (:chats (handlers/chat-list (h/components) {:limit 2}))))))
322+
323+
(testing "`sort-by :created-at` switches the sort key"
324+
(h/reset-components!)
325+
(seed-chats!
326+
{"x" {:id "x" :status :idle :created-at 30 :updated-at 100 :messages []}
327+
"y" {:id "y" :status :idle :created-at 20 :updated-at 200 :messages []}
328+
"z" {:id "z" :status :idle :created-at 10 :updated-at 300 :messages []}})
329+
(is (= ["x" "y" "z"]
330+
(mapv :id (:chats (handlers/chat-list (h/components)
331+
{:sort-by :created-at})))))))
332+
333+
(deftest chat-open-test
334+
(testing "Unknown chat returns {:found? false} and emits no messages"
335+
(h/reset-components!)
336+
(is (match? {:found? false}
337+
(handlers/chat-open (h/components) {:chat-id "missing"})))
338+
(is (nil? (:chat-opened (h/messages)))))
339+
340+
(testing "Subagent chat is treated as not found"
341+
(h/reset-components!)
342+
(seed-chats!
343+
{"sub" {:id "sub" :status :idle :subagent true :parent-chat-id "main"
344+
:messages [] :created-at 1 :updated-at 1}})
345+
(is (match? {:found? false}
346+
(handlers/chat-open (h/components) {:chat-id "sub"})))
347+
(is (nil? (:chat-opened (h/messages)))))
348+
349+
(testing "Known chat emits chat/cleared + chat/opened and returns found? true"
350+
(h/reset-components!)
351+
(seed-chats!
352+
{"c1" {:id "c1" :title "Hello world" :status :idle
353+
:created-at 10 :updated-at 20
354+
:messages [{:role "user" :content [{:type :text :text "hi"}]}]}})
355+
(let [result (handlers/chat-open (h/components) {:chat-id "c1"})]
356+
(is (match? {:found? true
357+
:chat-id "c1"
358+
:title "Hello world"}
359+
result))
360+
(is (match? {:chat-clear [{:chat-id "c1" :messages true}]
361+
:chat-opened [{:chat-id "c1" :title "Hello world"}]}
362+
(h/messages))))))

0 commit comments

Comments
 (0)