framework-ify. proper agent runtime + adopt ag-ui
CI / Test + coverage (pull_request) Successful in 1m41s

This commit is contained in:
2026-09-21 18:34:19 -09:00
parent 187252177c
commit a3ef6ec077
26 changed files with 1514 additions and 1200 deletions
+3
View File
@@ -48,6 +48,9 @@
:status :idle
:error nil
:pending-calls nil
;; Tool calls the server is still streaming (AG-UI's TOOL_CALL_START/ARGS/END), keyed by
;; toolCallId, until each is complete and moves into :pending-calls.
:tool-call-buffer {}
:thinking-at nil
:tool-calls-used 0}
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
+78 -35
View File
@@ -28,12 +28,17 @@
(.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %))))))
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
(def ^:private connection-lost {:error "the connection was lost"})
(def ^:private connection-lost {:type "RUN_ERROR" :message "the connection was lost"})
(def ^:private terminal-event-types
"The AG-UI event types (see tbd.chat.routes) that end a run."
#{"RUN_FINISHED" "RUN_ERROR"})
(defn- read-events!
"Dispatches `(conj on-event event)` for each server-sent event (see tbd.ui.sse) in the body read
by `reader`, as they arrive. A body that ends without a :done or :error event (the connection
dropped) is an :error event, so whoever waits for the end always gets one."
"Dispatches `(conj on-event event)` for each AG-UI event (see tbd.ui.sse, tbd.chat.routes) in the
body read by `reader`, as they arrive. A body that ends without a RUN_FINISHED or RUN_ERROR event
(the connection dropped) is a RUN_ERROR event of its own, so whoever waits for the end always
gets one."
[reader on-event]
(let [decoder (js/TextDecoder. "utf-8")
ended? (atom false)
@@ -48,7 +53,7 @@
(let [[events pending] (sse/parse (str pending (.decode decoder (.-value chunk)
#js {:stream true})))]
(doseq [event events]
(when (or (:done event) (:error event)) (reset! ended? true))
(when (contains? terminal-event-types (:type event)) (reset! ended? true))
(rf/dispatch (conj on-event event)))
(step pending)))))
(.catch (fn [_] (lost!)))))]
@@ -306,14 +311,25 @@
truncated)
:else text))
(defonce ^:private id-counter (atom 0))
(defn- next-id
"An id unique within this page's session, for an AG-UI message: they only have to identify a
message within one run's conversation, never across page loads."
[]
(str "m" (swap! id-counter inc)))
(defn- wire-message
"A message of the conversation as tbd.chat.schema/Message has it."
"A message of the conversation as AG-UI's Message has it (see tbd.chat.schema/Message: user,
assistant with `toolCalls`, or tool)."
[{:keys [role content tool-calls tool-call-id]}]
(case role
"assistant" (cond-> {:role role :content content}
(seq tool-calls) (assoc :tool-calls tool-calls))
"tool" {:role role :tool-call-id tool-call-id :content content}
{:role role :content content}))
(cond-> {:id (next-id) :role role}
(= "tool" role) (assoc :toolCallId tool-call-id :content content)
(not= "tool" role) (assoc :content content)
(seq tool-calls) (assoc :toolCalls (mapv (fn [{:keys [id name arguments]}]
{:id id :type "function"
:function {:name name :arguments arguments}})
tool-calls))))
(defn- recent
"The most recent `max-sent-messages` of `messages`, without the results of calls whose asking
@@ -340,16 +356,23 @@
(assoc :content stale-result))))
messages)))
;; The effect that sends the conversation: it goes with the page being shown (`page`, its id, for
;; the LLM to know where the user is) and the tools that page offers the LLM. Both are read from
;; the db every time, so a conversation that moves between pages (the LLM's own `navigate`, say)
;; always tells the LLM where it is now.
(defn- wire-tool
"A tool as AG-UI's Tool has it (see tbd.chat.schema/Tool): `parameters`, not MCP's `inputSchema`."
[{:keys [name description inputSchema]}]
(cond-> {:name name :parameters inputSchema}
description (assoc :description description)))
;; The request that sends the conversation: an AG-UI RunAgentInput. It goes with the page being
;; shown (as a `context` entry, for the LLM to know where the user is) and the tools that page
;; offers the LLM. Both are read from the db every time, so a conversation that moves between
;; pages (the LLM's own `navigate`, say) always tells the LLM where it is now.
(defn- chat-request [db messages]
(let [tools (model-context/definitions (:page db))]
{:uri "/api/chat" :method "POST"
:body (cond-> {:messages (mapv wire-message (without-stale-results (recent messages)))
:page (name (:page db))}
(seq tools) (assoc :tools tools))
:body (cond-> {:threadId (next-id) :runId (next-id)
:messages (mapv wire-message (without-stale-results (recent messages)))
:context [{:description "page" :value (name (:page db))}]}
(seq tools) (assoc :tools (mapv wire-tool tools)))
:on-event [::chat-event]
:on-response [::chat-response]}))
@@ -423,13 +446,17 @@
(defn- append-to-reply
"The conversation's assistant message being written, with `text` added to its content. The first
piece starts the message, the rest are added to it."
piece starts the message, the rest are added to it. Leading whitespace is dropped while nothing
has been said yet (the model often opens its answer with a stray blank line right after
reasoning), but preserved once the message has content, since it may be meaningful there."
[db text]
(update-in db [:chat :messages]
(fn [messages]
(if (= "assistant" (:role (peek messages)))
(update messages (dec (count messages)) update :content str text)
(conj messages {:role "assistant" :content text})))))
(update messages (dec (count messages)) update :content
(fn [content]
(str content (if (str/blank? content) (str/triml text) text))))
(conj messages {:role "assistant" :content (str/triml text)})))))
(defn- with-tool-calls
"The conversation with the calls the LLM asked for: on the reply's text (the message it
@@ -473,26 +500,42 @@
(defn- text? [x]
(and (string? x) (seq x)))
;; One event of the reply, as the server sends them (see tbd.chat.routes): {:thinking "..."} for
;; the next piece of the model's reasoning (which is not kept: the user only sees that the model
;; is thinking, and for as long as pieces keep coming, which :thinking-at, the time the last one
;; came, is for), {:content "..."} for the next piece of the reply (the first starts the
;; assistant's message, the rest are added to it; one event can have both), {:tool-calls [{:id
;; :name :arguments}]} when it asks for tools to be called, and last {:done true} or {:error "..."}.
;; Events nobody is waiting for (the conversation was reset since) are dropped.
(defn- start-tool-call [db {:keys [toolCallId toolCallName]}]
(update-in db [:chat :tool-call-buffer] assoc toolCallId {:id toolCallId :name toolCallName :arguments ""}))
(defn- append-tool-call-args [db {:keys [toolCallId delta]}]
(update-in db [:chat :tool-call-buffer toolCallId :arguments] str delta))
(defn- end-tool-call [db {:keys [toolCallId]}]
(let [call (get-in db [:chat :tool-call-buffer toolCallId])]
(cond-> db
call (-> (update-in [:chat :pending-calls] (fnil conj []) call)
(update-in [:chat :tool-call-buffer] dissoc toolCallId)))))
;; One AG-UI event of the reply (see tbd.chat.routes, https://docs.ag-ui.com): TEXT_MESSAGE_CONTENT
;; ({:delta "..."}) for the next piece of the reply (the first starts the assistant's message, the
;; rest are added to it), REASONING_MESSAGE_CONTENT for the next piece of the model's reasoning
;; (which is not kept: the user only sees that the model is thinking, and for as long as pieces
;; keep coming, which :thinking-at, the time the last one came, is for),
;; TOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_END as it asks for tools to be called (accumulated in
;; :tool-call-buffer until each call is complete), and last RUN_FINISHED or RUN_ERROR. Events
;; nobody is waiting for (the conversation was reset since) are dropped.
(rf/reg-event-fx
::chat-event
[(rf/inject-cofx ::now)]
(fn [{:keys [db now]} [_ {:keys [thinking content tool-calls done error]}]]
(fn [{:keys [db now]} [_ {:keys [type] :as event}]]
(let [loading? (= :loading (get-in db [:chat :status]))
db (cond-> db (and loading? (text? thinking)) (assoc-in [:chat :thinking-at] now))]
db (cond-> db
(and loading? (= "REASONING_MESSAGE_CONTENT" type) (text? (:delta event)))
(assoc-in [:chat :thinking-at] now))]
(cond
(not loading?) {:db db}
(text? content) {:db (append-to-reply db content)}
tool-calls {:db (assoc-in db [:chat :pending-calls] (mapv #(select-keys % [:id :name :arguments])
tool-calls))}
done (finish-reply db)
error {:db (chat-failed db error)}
(= "TEXT_MESSAGE_CONTENT" type) {:db (append-to-reply db (:delta event))}
(= "TOOL_CALL_START" type) {:db (start-tool-call db event)}
(= "TOOL_CALL_ARGS" type) {:db (append-tool-call-args db event)}
(= "TOOL_CALL_END" type) {:db (end-tool-call db event)}
(= "RUN_FINISHED" type) (finish-reply db)
(= "RUN_ERROR" type) {:db (chat-failed db (:message event))}
:else {:db db}))))
(defn- call-all
+71 -21
View File
@@ -284,10 +284,30 @@
(dispatch! [::events/chat-set-input text])
(dispatch! [::events/chat-send]))
(defn- text? [x] (and (string? x) (seq x)))
(defn- tool-call-events [{:keys [id name arguments]}]
[{:type "TOOL_CALL_START" :toolCallId id :toolCallName name}
{:type "TOOL_CALL_ARGS" :toolCallId id :delta arguments}
{:type "TOOL_CALL_END" :toolCallId id}])
(defn- old->agui
"A script's old-shape event ({:thinking}/{:content}/{:tool-calls}/{:done}/{:error}, what the
server used to send) as the AG-UI events (see tbd.chat.routes) it now corresponds to."
[{:keys [thinking content tool-calls done error]}]
(cond-> []
(text? thinking) (conj {:type "REASONING_MESSAGE_CONTENT" :delta thinking})
(text? content) (conj {:type "TEXT_MESSAGE_CONTENT" :delta content})
tool-calls (into (mapcat tool-call-events) tool-calls)
done (conj {:type "RUN_FINISHED"})
error (conj {:type "RUN_ERROR" :message error})))
(defn- event!
"The stream the last request asked for delivers `event`."
"The stream the last request asked for delivers `event` (the old shape a script uses; see
old->agui), as the one or more AG-UI events it corresponds to."
[event]
(dispatch! (conj (:on-event (last-request)) event)))
(doseq [agui-event (old->agui event)]
(dispatch! (conj (:on-event (last-request)) agui-event))))
(defn- reply!
"The stream delivers `pieces` of the reply, and ends."
@@ -303,16 +323,29 @@
(dispatch! [::events/chat-toggle])
(is (false? (:open? (chat)))))
(defn- without-ids
"`messages`, as AG-UI has them on the wire, with the per-message `:id` (a counter, unpredictable
from a test) dropped, so what is left can be compared."
[messages]
(mapv #(dissoc % :id) messages))
(defn- sent-messages
"The `:messages` of `request` (a RunAgentInput), with their ids dropped."
[request]
(without-ids (get-in request [:body :messages])))
(deftest chat-send-test
(logged-in! [])
(say! " hello there ")
(testing "the trimmed message joins the conversation and goes to the server, for a stream back"
(is (= [::events/fetch-stream
{:uri "/api/chat" :method "POST"
:body {:messages [{:role "user" :content "hello there"}] :page "tic-tac-toe"}
:on-event [::events/chat-event]
:on-response [::events/chat-response]}]
(last @effects)))
(let [[effect request] (last @effects)]
(is (= ::events/fetch-stream effect))
(is (= "/api/chat" (:uri request)))
(is (= "POST" (:method request)))
(is (= [::events/chat-event] (:on-event request)))
(is (= [::events/chat-response] (:on-response request)))
(is (= [{:role "user" :content "hello there"}] (sent-messages request)))
(is (= [{:description "page" :value "tic-tac-toe"}] (get-in request [:body :context]))))
(is (= {:messages [{:role "user" :content "hello there"}] :input "" :status :loading :error nil}
(select-keys (chat) [:messages :input :status :error]))))
(testing "only one message is in flight, so sending again does nothing"
@@ -327,7 +360,7 @@
(is (= [{:role "user" :content "hello there"}
{:role "assistant" :content "Hi!"}
{:role "user" :content "how are you"}]
(get-in (last-request) [:body :messages])))))
(sent-messages (last-request))))))
(deftest chat-streaming-reply-test
(logged-in! [])
@@ -363,7 +396,20 @@
(say! "again")
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hello, world"}
{:role "user" :content "again"}]
(get-in (last-request) [:body :messages])))))
(sent-messages (last-request))))))
(deftest chat-reply-drops-leading-blank-lines-test
;; The model often opens its answer with a stray blank line right after reasoning; the UI drops
;; that so the reply doesn't render with empty space above it, but leaves whitespace alone once
;; the message has content.
(logged-in! [])
(say! "hello")
(event! {:content "\n\n"})
(event! {:content "Hi"})
(event! {:content "\n\nthere"})
(event! {:done true})
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hi\n\nthere"}]
(:messages (chat)))))
(deftest chat-thinking-time-test
;; The view animates "Thinking" while pieces of the reasoning are still coming in: the time of
@@ -528,8 +574,8 @@
(answer! 200 {:logged-out true})
(is (= (:chat db/default-db) (chat)))
(testing "what the stream still delivers is dropped"
(dispatch! (conj (:on-event request) {:content "late"}))
(dispatch! (conj (:on-event request) {:done true}))
(dispatch! (conj (:on-event request) {:type "TEXT_MESSAGE_CONTENT" :delta "late"}))
(dispatch! (conj (:on-event request) {:type "RUN_FINISHED"}))
(dispatch! (conj (:on-response request) {:ok? false :status 502 :body "{:error \"x\"}"}))
(is (= (:chat db/default-db) (chat))))))
@@ -565,23 +611,26 @@
(testing "no tools on the page, none offered"
(say! "hi")
(is (not (contains? (:body (last-request)) :tools))))
(testing "the page's tools go along with the conversation, as MCP describes them"
(testing "the page's tools go along with the conversation, as AG-UI describes them"
(reply! "hello")
(model-context/register! [{:name "add" :description "Adds" :input-schema add-schema :handler identity}])
(say! "again")
(is (= [{:name "add" :description "Adds" :inputSchema add-schema}]
(is (= [{:name "add" :description "Adds" :parameters add-schema}]
(:tools (:body (last-request)))))))
(defn- page-of [request]
(some #(when (= "page" (:description %)) (:value %)) (get-in request [:body :context])))
(deftest chat-says-which-page-it-is-on-test
(logged-in! [])
(testing "the page the user is on goes along with the conversation"
(say! "hi")
(is (= "tic-tac-toe" (:page (:body (last-request))))))
(is (= "tic-tac-toe" (page-of (last-request)))))
(testing "and follows them, or the LLM itself, to the next page"
(reply! "hello")
(dispatch! [::events/navigate :ai-setup])
(say! "again")
(is (= "ai-setup" (:page (:body (last-request))))))
(is (= "ai-setup" (page-of (last-request)))))
(testing "a page the LLM opened with its tools is the page its next request comes from"
(reply! "hello")
(model-context/register! [{:name "go" :description "Opens the todos" :input-schema add-schema
@@ -591,7 +640,7 @@
(dispatch! [::events/navigate :todos]) ; what the tool did
(ran! ["c1" "go" "ok" false])
(is (= "todos" (:page (:body (last-request)))))))
(is (= "todos" (page-of (last-request))))))
(deftest chat-offers-the-tools-of-the-page-test
(logged-in! [])
@@ -656,9 +705,10 @@
(last (:messages (chat)))))
(is (= 2 (count (requests))))
(is (= [{:role "user" :content "1+2?"}
{:role "assistant" :content "" :tool-calls [(call "c1" "add" "{\"x\":1,\"y\":2}")]}
{:role "tool" :tool-call-id "c1" :content "3"}]
(get-in (last-request) [:body :messages])) "as the server has them: only what the LLM needs")
{:role "assistant" :content ""
:toolCalls [{:id "c1" :type "function" :function {:name "add" :arguments "{\"x\":1,\"y\":2}"}}]}
{:role "tool" :toolCallId "c1" :content "3"}]
(sent-messages (last-request))) "as the server has them: only what the LLM needs")
(is (= 1 (count (:tools (:body (last-request))))) "and the tools are still on offer"))
(testing "the answer is a new message, and ends it"
(reply! "It's " "3.")
@@ -788,7 +838,7 @@
(testing "a result from before the user's last message is no longer sent, its call and reply are"
(let [sent (get-in (last-request) [:body :messages])]
(is (= ["user" "assistant" "tool" "assistant" "user"] (map :role sent)))
(is (= "old" (:tool-call-id (nth sent 2))))
(is (= "old" (:toolCallId (nth sent 2))))
(is (str/includes? (:content (nth sent 2)) "out of date"))
(is (not (str/includes? (:content (nth sent 2)) "\"turn\"")) "what it read is gone")
(is (= "Your move." (:content (nth sent 3))))))
+28 -11
View File
@@ -53,7 +53,7 @@
(let [asked (atom nil)]
(tu/finish
(tu/steps
#(stream! (streaming asked (sse {:done true})) 1)
#(stream! (streaming asked (sse {:type "RUN_FINISHED"})) 1)
(fn [_]
(is (= ["/api/chat" {:method "POST"
:headers {:Content-Type "application/edn"}
@@ -65,9 +65,15 @@
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:thinking "hm"} {:content "Hel"}) (sse {:content "lo"} {:done true})) 4)
#(stream! (streaming (atom nil)
(sse {:type "REASONING_MESSAGE_CONTENT" :delta "hm"} {:type "TEXT_MESSAGE_CONTENT" :delta "Hel"})
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "lo"} {:type "RUN_FINISHED"}))
4)
(fn [got]
(is (= [[:event {:thinking "hm"}] [:event {:content "Hel"}] [:event {:content "lo"}] [:event {:done true}]]
(is (= [[:event {:type "REASONING_MESSAGE_CONTENT" :delta "hm"}]
[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hel"}]
[:event {:type "TEXT_MESSAGE_CONTENT" :delta "lo"}]
[:event {:type "RUN_FINISHED"}]]
got)
"each event as it arrives, and nothing more when it ended properly")))
done)))
@@ -75,8 +81,8 @@
(deftest split-anywhere-test
;; The network cuts the stream wherever it likes: in a line, and in a multi-byte character too.
(async done
(let [bytes (sse {:content "héllo 👋 世界"} {:done true})
expected [[:event {:content "héllo 👋 世界"}] [:event {:done true}]]]
(let [bytes (sse {:type "TEXT_MESSAGE_CONTENT" :delta "héllo 👋 世界"} {:type "RUN_FINISHED"})
expected [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "héllo 👋 世界"}] [:event {:type "RUN_FINISHED"}]]]
(tu/finish
(apply tu/steps
(for [cut (range 1 (.-length bytes))]
@@ -90,25 +96,36 @@
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:content "Hi"})) 2)
#(stream! (streaming (atom nil) (sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"})) 2)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "the connection was lost"}]] got))))
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
[:event {:type "RUN_ERROR" :message "the connection was lost"}]]
got))))
done)))
(testing "a read that fails in the middle of the stream"
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:content "Hi"}) :fail (sse {:content "never"})) 2)
#(stream! (streaming (atom nil) (sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}) :fail
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "never"}))
2)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "the connection was lost"}]] got))))
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
[:event {:type "RUN_ERROR" :message "the connection was lost"}]]
got))))
done)))
(testing "a stream that ended with an error of its own says so once"
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:content "Hi"} {:error "the LLM server took too long to answer"})) 2)
#(stream! (streaming (atom nil)
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}
{:type "RUN_ERROR" :message "the LLM server took too long to answer"}))
2)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "the LLM server took too long to answer"}]] got))))
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
[:event {:type "RUN_ERROR" :message "the LLM server took too long to answer"}]]
got))))
done))))
(deftest failed-request-test
+26 -5
View File
@@ -100,16 +100,37 @@
even in the middle of a character, as the network may."
7)
(defn- text? [x] (and (string? x) (seq x)))
(defn- tool-call-events [{:keys [id name arguments]}]
[{:type "TOOL_CALL_START" :toolCallId id :toolCallName name}
{:type "TOOL_CALL_ARGS" :toolCallId id :delta arguments}
{:type "TOOL_CALL_END" :toolCallId id}])
(defn- old->agui
"A script step, in the old shape tests write ({:thinking}/{:content}/{:tool-calls}/{:done}/
{:error}) as the AG-UI events (see tbd.chat.routes) it now corresponds to."
[{:keys [thinking content tool-calls done error]}]
(cond-> []
(text? thinking) (conj {:type "REASONING_MESSAGE_CONTENT" :delta thinking})
(text? content) (conj {:type "TEXT_MESSAGE_CONTENT" :delta content})
tool-calls (into (mapcat tool-call-events) tool-calls)
done (conj {:type "RUN_FINISHED"})
error (conj {:type "RUN_ERROR" :message error})))
(defn- script-items
"The script's events as the pieces of bytes that arrive, and :pause where the stream waits."
"The script's events (in the old shape a test writes; see old->agui) as the pieces of bytes that
arrive, and :pause where the stream waits."
[script]
(mapcat (fn [step]
(if (= :pause step)
[:pause]
(let [bytes (.encode (js/TextEncoder.)
(str "data: " (js/JSON.stringify (clj->js step)) "\n\n"))]
(for [i (range 0 (.-length bytes) piece-size)]
(.subarray bytes i (min (.-length bytes) (+ i piece-size)))))))
(mapcat (fn [event]
(let [bytes (.encode (js/TextEncoder.)
(str "data: " (js/JSON.stringify (clj->js event)) "\n\n"))]
(for [i (range 0 (.-length bytes) piece-size)]
(.subarray bytes i (min (.-length bytes) (+ i piece-size))))))
(old->agui step))))
script))
(defn- stream-reader
+5 -4
View File
@@ -560,7 +560,7 @@
[{:role "user" :content "hello"}
{:role "assistant" :content "echo: hello"}
{:role "user" :content "again"}]]
(:chats @tu/server))
(mapv #(mapv (fn [m] (dissoc m :id)) %) (:chats @tu/server)))
"each message carries the conversation so far")
(click! (first (filter #(= "Clear" (.-textContent %)) (q-all "[role=dialog] button")))))
#(tu/eventually (fn [] (= "Ask me anything." (text "[role=log] p"))))
@@ -848,9 +848,10 @@
(testing "the second request took the result to the LLM"
(is (= [{:role "user" :content "what is 2 + 3? use the calculator"}
{:role "assistant" :content "I'll add them."
:tool-calls [{:id "call_1" :name "add" :arguments "{\"x\":2,\"y\":3}"}]}
{:role "tool" :tool-call-id "call_1" :content "{\"x\":2,\"y\":3,\"sum\":5}"}]
(:messages (second (:chat-requests @tu/server))))))
:toolCalls [{:id "call_1" :type "function"
:function {:name "add" :arguments "{\"x\":2,\"y\":3}"}}]}
{:role "tool" :toolCallId "call_1" :content "{\"x\":2,\"y\":3,\"sum\":5}"}]
(mapv #(dissoc % :id) (:messages (second (:chat-requests @tu/server)))))))
(testing "and it is over: the panel takes messages again"
(is (false? (.-readOnly (q "input[name=message]")))))
(click! (tool-toggle)))