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