framework-ify. proper agent runtime + adopt ag-ui
CI / Test + coverage (pull_request) Successful in 1m41s
CI / Test + coverage (pull_request) Successful in 1m41s
This commit is contained in:
@@ -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))))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user