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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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