add integrated chatbot with streaming + tools
CI / Test + coverage (push) Successful in 1m23s

This commit is contained in:
2026-09-21 09:09:55 -09:00
parent 10f4ca654b
commit a53f0c3621
25 changed files with 3409 additions and 81 deletions
+4
View File
@@ -3,6 +3,7 @@
[re-frame.db :as rf-db]
[reagent.dom.client :as rdc]
[tbd.ui.core :as core]
[tbd.ui.model-context :as model-context]
[tbd.ui.test-util :as tu]
[tbd.ui.webmcp :as webmcp]))
@@ -36,6 +37,7 @@
(some-> (.getElementById js/document (.-elementId ^js w)) .remove))
(reset! @#'webmcp/widget nil)
(.clear js/sessionStorage)
(model-context/clear!)
;; init mounts the page in a root of its own.
(when-let [root @core/root]
(rdc/unmount root)
@@ -53,6 +55,8 @@
(fn []
(is (some? (.getElementById js/document (.-elementId ^js (widget)))) "the WebMCP widget is on the page")
(is (= (set (map :name webmcp/tools)) (tools-in-widget)) "it offers the page's tools to the agent")
(is (= (map :name (remove :pages webmcp/tools)) (map :name (model-context/definitions :ai-setup)))
"and to the chatbot, the ones of the page it is on")
;; A tool, as the agent's client calls it.
(let [ping (.-execute (.get (.-availableTools ^js (widget)) "ping"))]
(-> (ping #js {})
+568 -3
View File
@@ -4,6 +4,7 @@
[re-frame.db :as rf-db]
[tbd.ui.db :as db]
[tbd.ui.events :as events]
[tbd.ui.model-context :as model-context]
[tbd.ui.test-util :as tu]))
;; The handlers run for real; what they ask the outside world for (a request, the clipboard, a
@@ -19,8 +20,11 @@
(use-fixtures :each
tu/url-fixture
{:before (fn [] (reset! effects []) (reset! queued []))}
{:before (fn [] (reset! effects []) (reset! queued []) (model-context/clear!))
:after model-context/clear!}
(tu/stub-effects-fixture {::events/fetch (record ::events/fetch)
::events/fetch-stream (record ::events/fetch-stream)
::events/call-tools (record ::events/call-tools)
::events/copy-to-clipboard (record ::events/copy-to-clipboard)
:dispatch-later (record :dispatch-later)
:dispatch #(swap! queued conj %)}))
@@ -37,9 +41,10 @@
(defn- app-db [] @rf-db/app-db)
(defn- requests
"The requests asked for so far, as {:uri :method :body :on-response}."
"The requests asked for so far, as {:uri :method :body :on-response}. (Those for a stream of
events also have :on-event.)"
[]
(mapv second (filter (comp #{::events/fetch} first) @effects)))
(mapv second (filter (comp #{::events/fetch ::events/fetch-stream} first) @effects)))
(defn- last-request [] (last (requests)))
@@ -268,6 +273,566 @@
(is (= {:status :anonymous} (:user (app-db))) (str event))
(is (= (:todos db/default-db) (:todos (app-db))) "the todos are hidden and forgotten"))))
;; --- Chat ---
(defn- chat [] (:chat (app-db)))
(defn- say!
"Types `text` into the chat input and sends it."
[text]
(dispatch! [::events/chat-set-input text])
(dispatch! [::events/chat-send]))
(defn- event!
"The stream the last request asked for delivers `event`."
[event]
(dispatch! (conj (:on-event (last-request)) event)))
(defn- reply!
"The stream delivers `pieces` of the reply, and ends."
[& pieces]
(doseq [piece pieces] (event! {:content piece}))
(event! {:done true}))
(deftest chat-toggle-test
(start!)
(is (false? (:open? (chat))))
(dispatch! [::events/chat-toggle])
(is (true? (:open? (chat))))
(dispatch! [::events/chat-toggle])
(is (false? (:open? (chat)))))
(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 "games"}
:on-event [::events/chat-event]
:on-response [::events/chat-response]}]
(last @effects)))
(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"
(say! "and another")
(is (= 1 (count (requests))))
(is (= "and another" (:input (chat)))))
(testing "the reply joins the conversation, and the next message carries all of it"
(reply! "Hi!")
(is (= :idle (:status (chat))))
(dispatch! [::events/chat-set-input "how are you"])
(dispatch! [::events/chat-send])
(is (= [{:role "user" :content "hello there"}
{:role "assistant" :content "Hi!"}
{:role "user" :content "how are you"}]
(get-in (last-request) [:body :messages])))))
(deftest chat-streaming-reply-test
(logged-in! [])
(say! "hello")
(testing "the model's reasoning is not kept, and it keeps waiting"
(event! {:thinking "Let me "})
(event! {:thinking "think."})
(is (= [{:role "user" :content "hello"}] (:messages (chat))))
(is (= :loading (:status (chat)))))
(testing "the first piece of the reply, and the ones that follow, are added to that message"
(event! {:content "Hel"})
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hel"}]
(:messages (chat))))
(event! {:content "lo, "})
(event! {:content "world"})
(is (= [{:role "user" :content "hello"}
{:role "assistant" :content "Hello, world"}]
(:messages (chat))))
(is (= :loading (:status (chat))) "it isn't over until it says so"))
(testing "another message can't be sent while the reply is coming in"
(say! "and another")
(is (= 1 (count (requests)))))
(testing "done ends it"
(event! {:done true})
(is (= {:status :idle :error nil} (select-keys (chat) [:status :error])))
(is (= [{:role "user" :content "hello"}
{:role "assistant" :content "Hello, world"}]
(:messages (chat)))))
(testing "events that come after the end are dropped"
(event! {:content "late"})
(is (= "Hello, world" (:content (last (:messages (chat)))))))
(testing "the next message has the conversation so far"
(say! "again")
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hello, world"}
{:role "user" :content "again"}]
(get-in (last-request) [:body :messages])))))
(deftest chat-thinking-and-text-in-one-event-test
(logged-in! [])
(say! "hello")
(event! {:thinking "hm" :content "Hi"})
(event! {:thinking "m" :content "!"})
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hi!"}]
(:messages (chat))))
(testing "an empty piece changes nothing"
(event! {:thinking "" :content ""})
(is (= [{:role "user" :content "hello"} {:role "assistant" :content "Hi!"}]
(:messages (chat))))))
(deftest chat-thinking-is-never-kept-test
;; The reasoning of each reply of a turn, before and after tools, leaves nothing in the conversation.
(logged-in! [])
(say! "hi")
(event! {:thinking "first"})
(event! {:tool-calls [{:id "c1" :name "ping" :arguments "{}"}]})
(event! {:done true})
(dispatch! [::events/chat-tool-results [{:id "c1" :name "ping" :content "pong" :error? false}]])
(event! {:thinking "second"})
(event! {:content "done"})
(is (= [["user" nil] ["assistant" nil] ["tool" nil] ["assistant" nil]]
(map (juxt :role :thinking) (:messages (chat)))))
(is (not-any? #(re-find #"first|second" (pr-str %)) (:messages (chat)))))
(deftest chat-blank-message-test
(logged-in! [])
(say! " ")
(is (= [] (requests)) "a blank message isn't sent")
(is (= [] (:messages (chat)))))
(deftest chat-sends-the-recent-conversation-test
(logged-in! [])
(swap! rf-db/app-db assoc-in [:chat :messages]
(vec (for [i (range 60)] {:role (if (even? i) "user" "assistant") :content (str i)})))
(say! "latest")
(let [sent (get-in (last-request) [:body :messages])]
(is (= 40 (count sent)) "what the server accepts")
(is (vector? sent))
(is (= "latest" (:content (last sent))))
(is (= "21" (:content (first sent))) "the oldest part is what is left out")))
(deftest chat-failure-test
(logged-in! [])
(say! "hello")
(answer! 503 {:error "chat is not configured"})
(testing "the message goes back in the input, and the error is shown"
(is (= {:messages [] :input "hello" :status :idle :error "chat is not configured"}
(select-keys (chat) [:messages :input :status :error]))))
(testing "sending again clears the error, and then it can work"
(dispatch! [::events/chat-send])
(is (nil? (:error (chat))))
(reply! "Hi!")
(is (= ["hello" "Hi!"] (mapv :content (:messages (chat)))))))
(deftest chat-network-failure-test
(logged-in! [])
(say! "hello")
(dispatch! (conj (:on-response (last-request)) {:ok? false :status nil :body "Failed to fetch"}))
(is (= {:messages [] :input "hello" :status :idle :error "Failed to fetch"}
(select-keys (chat) [:messages :input :status :error]))))
(deftest chat-failure-in-the-stream-test
(testing "before any of the reply: like a request that failed, the message goes back in the input"
(logged-in! [])
(say! "hello")
(event! {:thinking "hmm"})
(event! {:error "the LLM server took too long to answer"})
(is (= {:messages [] :input "hello" :status :idle :error "the LLM server took too long to answer"}
(select-keys (chat) [:messages :input :status :error]))))
(testing "in the middle of the reply: what came stays, with the error, and the conversation goes on from it"
(logged-in! [])
(say! "hello")
(event! {:content "Hel"})
(event! {:error "the connection was lost"})
(is (= {:messages [{:role "user" :content "hello"} {:role "assistant" :content "Hel"}]
:input "" :status :idle :error "the connection was lost"}
(select-keys (chat) [:messages :input :status :error])))
(say! "again")
(is (nil? (:error (chat))))
(is (= ["hello" "Hel" "again"] (map :content (get-in (last-request) [:body :messages]))))))
(deftest chat-expired-session-test
(logged-in! [])
(dispatch! [::events/chat-toggle])
(say! "hello")
(answer! 401 {:error "login required"})
(is (= {:status :anonymous} (:user (app-db))) "falls back to the logged-out view")
(is (= (assoc (:chat db/default-db) :open? true) (chat)) "the conversation is forgotten, the panel stays open"))
(deftest chat-clear-test
(logged-in! [])
(say! "hello")
(testing "not while a reply is on its way"
(dispatch! [::events/chat-clear])
(is (= [{:role "user" :content "hello"}] (:messages (chat))))
(event! {:content "Hi"})
(dispatch! [::events/chat-clear])
(is (= 2 (count (:messages (chat))))))
(event! {:done true})
(dispatch! [::events/chat-toggle])
(dispatch! [::events/chat-clear])
(is (= (assoc (:chat db/default-db) :open? true) (chat)) "starts over, and the panel stays as it was"))
(deftest chat-resize-test
(logged-in! [])
(is (= {:width 320 :height 256} (:size (chat))) "the size it starts with")
(dispatch! [::events/chat-resize 500 400])
(is (= {:width 500 :height 400} (:size (chat))))
(testing "the panel keeps its size when the conversation starts over"
(say! "hello")
(reply! "Hi!")
(dispatch! [::events/chat-clear])
(is (empty? (:messages (chat))))
(is (= {:width 500 :height 400} (:size (chat)))))
(testing "and when its owner logs out"
(dispatch! [::events/logout])
(answer! 200 {:logged-out true})
(is (= {:width 500 :height 400} (:size (chat))))))
(deftest chat-forgotten-on-logout-test
(logged-in! [])
(say! "hello")
(reply! "Hi!")
(say! "and again")
(let [request (last-request)]
(event! {:content "partial"})
(dispatch! [::events/logout])
(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-response request) {:ok? false :status 502 :body "{:error \"x\"}"}))
(is (= (:chat db/default-db) (chat))))))
;; --- Chat: the LLM uses the page's tools ---
(def ^:private add-schema {:type "object" :properties {:x {:type "number"}} :required ["x"]})
(defn- call [id tool-name arguments]
{:id id :name tool-name :arguments arguments})
(defn- tool-calls-asked
"What the page was asked to do, by the ::call-tools effects so far: {:page :calls :on-results}."
[]
(mapv second (filter (comp #{::events/call-tools} first) @effects)))
(defn- asks!
"The stream delivers a request for `calls`, and ends."
[& calls]
(event! {:tool-calls (vec calls)})
(event! {:done true}))
(defn- ran!
"The tools the last request for calls asked for have run, with these results: [id name content
error?] each."
[& results]
(dispatch! (conj (:on-results (last (tool-calls-asked)))
(mapv (fn [[id tool-name content error?]]
{:id id :name tool-name :content content :error? error?})
results))))
(deftest chat-offers-the-tools-test
(logged-in! [])
(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"
(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}]
(:tools (:body (last-request)))))))
(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 (= "games" (:page (:body (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))))))
(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
:handler identity}])
(say! "go to the todos")
(asks! (call "c1" "go" "{}"))
(dispatch! [::events/navigate :todos]) ; what the tool did
(ran! ["c1" "go" "ok" false])
(is (= "todos" (:page (:body (last-request)))))))
(deftest chat-offers-the-tools-of-the-page-test
(logged-in! [])
(model-context/register! [{:name "ping" :description "Pings" :input-schema add-schema :handler identity}
{:name "add" :pages #{:games} :description "Adds" :input-schema add-schema :handler identity}
{:name "list_todos" :pages #{:todos} :description "Lists" :input-schema add-schema
:handler identity}])
(let [offered (fn [] (map :name (:tools (:body (last-request)))))]
(testing "on a page, its tools and those of every page are offered"
(say! "hi")
(is (= :games (:page (app-db))))
(is (= ["ping" "add"] (offered))))
(testing "the tools follow the page the user is on when the message is sent"
(reply! "hello")
(dispatch! [::events/navigate :todos])
(say! "again")
(is (= ["ping" "list_todos"] (offered))))
(testing "a page with no tools of its own is offered the tools of every page"
(reply! "hello")
(dispatch! [::events/navigate :ai-setup])
(say! "once more")
(is (= ["ping"] (offered))))))
(deftest chat-tool-calls-go-by-the-page-test
(logged-in! [])
(model-context/register! [{:name "add" :pages #{:games} :description "Adds" :input-schema add-schema :handler identity}])
(dispatch! [::events/navigate :todos])
(say! "1+2?")
(asks! (call "c1" "add" "{}"))
(testing "the calls are made on the tools of the page the user is on, not those of the first page"
(is (= [:todos] (map :page (tool-calls-asked)))))
(testing "the follow-up request follows the page too: the user may have gone elsewhere meanwhile"
(dispatch! [::events/navigate :games])
(ran! ["c1" "add" "tool not available on this page: add" true])
(is (= ["add"] (map :name (:tools (:body (last-request))))))))
(deftest chat-tool-call-test
(logged-in! [])
(model-context/register! [{:name "add" :description "Adds" :input-schema add-schema :handler identity}])
(say! "1+2?")
(testing "while the model reasons or writes a call, nothing is done, and the conversation has neither"
(event! {:thinking "I should add."})
(event! {:tool-calls [(call "c1" "add" "{\"x\":1,\"y\":2}")]})
(is (= [{:role "user" :content "1+2?"}] (:messages (chat))))
(is (= [] (tool-calls-asked))))
(testing "when the reply is over, the calls join the conversation and are made"
(event! {:done true})
(is (= [{:role "user" :content "1+2?"}
{:role "assistant" :content ""
:tool-calls [(call "c1" "add" "{\"x\":1,\"y\":2}")]}]
(:messages (chat))))
(is (= :loading (:status (chat))) "the LLM still has to answer")
(is (= [{:page :games :calls [(call "c1" "add" "{\"x\":1,\"y\":2}")] :on-results [::events/chat-tool-results]}]
(tool-calls-asked)))
(is (= 1 (count (requests))) "nothing is sent while the tools run"))
(testing "another message can't be sent meanwhile"
(say! "and another")
(is (= 1 (count (requests)))))
(testing "the results join the conversation, and go to the LLM, calls and all"
(ran! ["c1" "add" "3" false])
(is (= {:role "tool" :tool-call-id "c1" :name "add" :content "3" :error? false}
(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")
(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.")
(is (= [{:role "assistant" :content "It's 3."}] (take-last 1 (:messages (chat)))))
(is (= {:status :idle :error nil} (select-keys (chat) [:status :error])))
(is (nil? (:pending-calls (chat))))))
(deftest chat-tool-call-with-text-test
(logged-in! [])
(say! "hi")
(event! {:content "Let me "})
(event! {:content "check."})
(asks! (call "a" "ping" "{}") (call "b" "whoami" ""))
(testing "the calls are made on the message that says what it does, in order"
(is (= [{:role "user" :content "hi"}
{:role "assistant" :content "Let me check."
:tool-calls [(call "a" "ping" "{}") (call "b" "whoami" "")]}]
(:messages (chat))))
(is (= [(call "a" "ping" "{}") (call "b" "whoami" "")] (:calls (last (tool-calls-asked))))))
(testing "their results follow in the same order, one message each"
(ran! ["a" "ping" "pong" false] ["b" "whoami" "not logged in" false])
(is (= [["tool" "a"] ["tool" "b"]]
(map (juxt :role :tool-call-id) (take-last 2 (:messages (chat))))))))
(deftest chat-tool-calls-go-on-test
(logged-in! [])
(say! "do it")
(testing "the LLM may ask again after the results, and again, until it answers"
(asks! (call "a" "ping" "{}"))
(ran! ["a" "ping" "pong" false])
(asks! (call "b" "ping" "{}"))
(ran! ["b" "ping" "pong" false])
(is (= 3 (count (requests))))
(is (= :loading (:status (chat))))
(reply! "Done.")
(is (= ["user" "assistant" "tool" "assistant" "tool" "assistant"] (map :role (:messages (chat)))))
(is (= :idle (:status (chat)))))
(testing "a new message starts a new turn, with all the calls it may make"
(dispatch! [::events/chat-set-input "again"])
(dispatch! [::events/chat-send])
(is (zero? (:tool-calls-used (chat))))))
(deftest chat-tool-call-budget-test
(logged-in! [])
(say! "go wild")
(testing "a turn may make 12 calls, as many rounds as it takes"
(dotimes [i 4]
(asks! (call (str "a" i) "ping" "{}") (call (str "b" i) "ping" "{}") (call (str "c" i) "ping" "{}"))
(ran! [(str "a" i) "ping" "pong" false] [(str "b" i) "ping" "pong" false] [(str "c" i) "ping" "pong" false]))
(is (= 12 (:tool-calls-used (chat))))
(is (= :loading (:status (chat))))
(is (= 5 (count (requests))))
(is (<= (count (get-in (last-request) [:body :messages])) 40) "and it all still fits what the server takes"))
(testing "one more is too many: it is answered, but not made, and the turn is over"
(asks! (call "z" "ping" "{}"))
(is (= 4 (count (tool-calls-asked))) "no more asked of the page")
(is (= 5 (count (requests))) "nor of the LLM")
(is (= {:role "tool" :tool-call-id "z" :name "ping" :content "not run: too many tool calls in one turn"
:error? true}
(last (:messages (chat)))) "the conversation stays whole: every call has its answer")
(is (= "assistant" (:role (nth (:messages (chat)) (- (count (:messages (chat))) 2)))))
(is (= {:status :idle :error "The assistant made too many tool calls in a row, so it was stopped."}
(select-keys (chat) [:status :error]))))
(testing "the next message goes on from there"
(say! "ok, stop")
(is (nil? (:error (chat))))
(is (zero? (:tool-calls-used (chat))))
(is (= "user" (:role (last (get-in (last-request) [:body :messages])))))))
(deftest chat-tool-results-nobody-waits-for-test
(logged-in! [])
(say! "hi")
(asks! (call "a" "ping" "{}"))
(let [asked (last (tool-calls-asked))]
(testing "results for other calls than the ones asked for are dropped"
(dispatch! (conj (:on-results asked) [{:id "other" :name "ping" :content "pong" :error? false}]))
(is (= 1 (count (requests))))
(is (= "assistant" (:role (last (:messages (chat)))))))
(testing "and so are results that come when the conversation was reset meanwhile"
(dispatch! [::events/logout])
(answer! 200 {:logged-out true})
(dispatch! (conj (:on-results asked) [{:id "a" :name "ping" :content "pong" :error? false}]))
(is (= 1 (count (filter #(= "/api/chat" (:uri %)) (requests)))) "nothing more went to the LLM")
(is (= (:chat db/default-db) (chat))))))
(deftest chat-tool-results-are-answered-once-test
(logged-in! [])
(say! "hi")
(asks! (call "a" "ping" "{}"))
(ran! ["a" "ping" "pong" false])
(ran! ["a" "ping" "pong" false])
(is (= 2 (count (requests))) "the same results again change nothing")
(is (= 3 (count (:messages (chat))))))
(deftest chat-failure-after-tools-test
(logged-in! [])
(say! "hi")
(asks! (call "a" "ping" "{}"))
(testing "the LLM can't be reached with the results: they stay, and the error is shown"
(ran! ["a" "ping" "pong" false])
(answer! 502 {:error "the LLM server can't be reached"})
(is (= ["user" "assistant" "tool"] (map :role (:messages (chat)))) "no message goes back in the input")
(is (= {:input "" :status :idle :error "the LLM server can't be reached"}
(select-keys (chat) [:input :status :error]))))
(testing "the conversation goes on from there"
(say! "hello?")
(is (= ["user" "assistant" "tool" "user"] (map :role (get-in (last-request) [:body :messages]))))))
(deftest chat-failure-with-calls-coming-test
(logged-in! [])
(say! "hi")
(event! {:tool-calls [(call "a" "ping" "{}")]})
(event! {:error "the connection was lost"})
(testing "calls that never got to be made are forgotten, like any reply that didn't come"
(is (= {:messages [] :input "hi" :status :idle :error "the connection was lost" :pending-calls nil}
(select-keys (chat) [:messages :input :status :error :pending-calls])))
(is (= [] (tool-calls-asked)))))
(deftest chat-sends-no-orphaned-result-test
(logged-in! [])
;; The oldest message is the one that asked for the call whose result comes next, and it is what
;; falls out of the 40 that are sent.
(swap! rf-db/app-db assoc-in [:chat :messages]
(into [{:role "assistant" :content "" :tool-calls [(call "old" "ping" "{}")]}
{:role "tool" :tool-call-id "old" :name "ping" :content "pong" :error? false}]
(for [i (range 38)] {:role (if (even? i) "user" "assistant") :content (str i)})))
(say! "latest")
(let [sent (get-in (last-request) [:body :messages])]
(is (= "user" (:role (first sent))) "a result the LLM can't match to a call would be refused")
(is (= "0" (:content (first sent))))
(is (= "latest" (:content (last sent))))))
(deftest call-tools-effect-page-test
;; The real effect: a tool is called only on a page that offers it.
(let [call-tools-fx (tu/real-effect ::events/call-tools)
answer (atom nil)]
(rf/reg-event-fx ::ran (fn [_ [_ results]] (@answer results) {}))
(model-context/register!
[{:name "games-only" :pages #{:games} :description "d" :input-schema add-schema :handler (fn [_] "played")}
{:name "anywhere" :description "d" :input-schema add-schema :handler (fn [_] "ok")}])
(async done
(tu/finish
(tu/steps
#(js/Promise. (fn [resolve]
(reset! answer resolve)
(call-tools-fx {:page :todos
:calls [(call "1" "games-only" "{}") (call "2" "anywhere" "{}")]
:on-results [::ran]})))
(fn [results]
(is (= [["tool not available on this page: games-only (it is offered on the games page)" true]
["ok" false]]
(map (juxt :content (comp boolean :error?)) results))))
#(js/Promise. (fn [resolve]
(reset! answer resolve)
(call-tools-fx {:page :games :calls [(call "1" "games-only" "{}")] :on-results [::ran]})))
(fn [results] (is (= ["played"] (map :content results)))))
done))))
(deftest call-tools-effect-test
;; The real effect, on real tools; the results are dispatched to ::ran.
(let [call-tools-fx (tu/real-effect ::events/call-tools)
order (atom [])
answer (atom nil)]
(rf/reg-event-fx ::ran (fn [_ [_ results]] (@answer results) {}))
(model-context/register!
[{:name "slow" :description "d" :input-schema add-schema
:handler (fn [_] (js/Promise. (fn [resolve] (js/setTimeout #(do (swap! order conj :slow) (resolve "slow done")) 20))))}
{:name "fast" :description "d" :input-schema add-schema
:handler (fn [args] (swap! order conj :fast) (str "fast " (pr-str args)))}
{:name "huge" :description "d" :input-schema add-schema :handler (fn [_] (apply str (repeat 9000 "x")))}
{:name "blank" :description "d" :input-schema add-schema :handler (fn [_] " ")}
{:name "fails" :description "d" :input-schema add-schema :handler (fn [_] (throw (js/Error. "it broke")))}])
(async done
(tu/finish
(tu/steps
#(js/Promise. (fn [resolve]
(reset! answer resolve)
(call-tools-fx {:calls [(call "1" "slow" "{}")
(call "2" "fast" "{\"x\":1}")
(call "3" "huge" "{}")
(call "4" "blank" "")
(call "5" "fails" "{}")
(call "6" "nope" "{}")
(call "7" "fast" "oops")]
:on-results [::ran]})))
(fn [results]
(testing "one after the other, in order: the tools work on the same page"
(is (= [:slow :fast] @order)))
(testing "a result for each call, whatever became of it"
(is (= ["1" "2" "3" "4" "5" "6" "7"] (map :id results)))
(is (= ["slow" "fast" "huge" "blank" "fails" "nope" "fast"] (map :name results)))
(is (= ["slow done" "fast {:x 1}"] (map :content (take 2 results)))))
(testing "a result is never empty, and never longer than the server takes"
(is (= "(no output)" (:content (nth results 3))))
(is (= 8000 (count (:content (nth results 2)))))
(is (= "xxx … (truncated)" (subs (:content (nth results 2)) 7983))))
(testing "failures are results for the LLM to read"
(is (= [false false false false true true true] (map (comp boolean :error?) results)))
(is (= ["it broke" "unknown tool: nope" "invalid arguments: they must be a JSON object"]
(map :content (drop 4 results))))))
#(js/Promise. (fn [resolve]
(reset! answer resolve)
(call-tools-fx {:calls [] :on-results [::ran]})))
(fn [results] (is (= [] results) "no calls, no results")))
done))))
;; --- MCP setup ---
(deftest copy-mcp-command-test
+132
View File
@@ -0,0 +1,132 @@
(ns tbd.ui.fetch-stream-test
"The ::fetch-stream effect (what the chat's replies come in through), run for real against fakes
of fetch that fail in the ways a network does."
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
[re-frame.core :as rf]
[tbd.ui.events :as events]
[tbd.ui.test-util :as tu]))
(use-fixtures :each tu/fake-server-fixture)
(def ^:private got (atom []))
(rf/reg-event-fx ::got-event (fn [_ [_ event]] (swap! got conj [:event event]) nil))
(rf/reg-event-fx ::got-response (fn [_ [_ response]] (swap! got conj [:response response]) nil))
(defn- bytes-of [text] (.encode (js/TextEncoder.) text))
(defn- reader
"A body reader that gives `reads` one at a time: bytes, or :fail for a read that fails. Then it's done."
[reads]
(let [reads (atom reads)]
#js {:read (fn []
(let [r (first @reads)]
(swap! reads rest)
(cond
(nil? r) (js/Promise.resolve #js {:done true})
(= :fail r) (js/Promise.reject (js/Error. "network error"))
:else (js/Promise.resolve #js {:done false :value r}))))}))
(defn- streaming
"A fetch that answers 200 with a body of `reads` (see `reader`), and notes what it was asked."
[asked & reads]
(fn [uri opts]
(reset! asked [uri (js->clj opts :keywordize-keys true)])
(js/Promise.resolve #js {:ok true :status 200 :body #js {:getReader (fn [] (reader reads))}})))
(defn- stream!
"Runs the effect against `fetch`, and gives a promise of what was dispatched once `n` things were
(and a moment more, for anything that shouldn't come)."
[fetch n]
(reset! got [])
(aset js/globalThis "fetch" fetch)
((tu/real-effect ::events/fetch-stream)
{:uri "/api/chat" :method "POST" :body {:x 1} :on-event [::got-event] :on-response [::got-response]})
(-> (tu/eventually #(>= (count @got) n))
(.then (fn [_] (js/Promise. (fn [resolve] (js/setTimeout #(resolve @got) 30)))))))
(defn- sse [& events]
(bytes-of (apply str (map #(str "data: " (js/JSON.stringify (clj->js %)) "\n\n") events))))
(deftest request-test
(async done
(let [asked (atom nil)]
(tu/finish
(tu/steps
#(stream! (streaming asked (sse {:done true})) 1)
(fn [_]
(is (= ["/api/chat" {:method "POST"
:headers {:Content-Type "application/edn"}
:body "{:x 1}"}]
@asked))))
done))))
(deftest events-test
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:thinking "hm"} {:content "Hel"}) (sse {:content "lo"} {:done true})) 4)
(fn [got]
(is (= [[:event {:thinking "hm"}] [:event {:content "Hel"}] [:event {:content "lo"}] [:event {:done true}]]
got)
"each event as it arrives, and nothing more when it ended properly")))
done)))
(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}]]]
(tu/finish
(apply tu/steps
(for [cut (range 1 (.-length bytes))]
(fn [_]
(-> (stream! (streaming (atom nil) (.subarray bytes 0 cut) (.subarray bytes cut)) 2)
(.then (fn [got] (is (= expected got) (str "cut at byte " cut))))))))
done))))
(deftest lost-connection-test
(testing "a stream that ends without saying it is over"
(async done
(tu/finish
(tu/steps
#(stream! (streaming (atom nil) (sse {:content "Hi"})) 2)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "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)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "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)
(fn [got]
(is (= [[:event {:content "Hi"}] [:event {:error "the LLM server took too long to answer"}]] got))))
done))))
(deftest failed-request-test
(testing "a status that isn't 2xx: no stream, the response is given as ::fetch gives it"
(async done
(tu/finish
(tu/steps
#(stream! (fn [_ _] (js/Promise.resolve #js {:ok false :status 502
:text (fn [] (js/Promise.resolve "{:error \"down\"}"))}))
1)
(fn [got]
(is (= [[:response {:ok? false :status 502 :body "{:error \"down\"}"}]] got))))
done)))
(testing "a request that fails outright"
(async done
(tu/finish
(tu/steps
#(stream! (fn [_ _] (js/Promise.reject (js/Error. "Failed to fetch"))) 1)
(fn [got]
(is (= [[:response {:ok? false :status nil :body "Failed to fetch"}]] got))))
done))))
+146
View File
@@ -0,0 +1,146 @@
(ns tbd.ui.model-context-test
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
[tbd.ui.model-context :as model-context]
[tbd.ui.test-util :as tu]))
(use-fixtures :each
{:before model-context/clear!
:after model-context/clear!})
(def ^:private schema {:type "object" :properties {} :required []})
(defn- tool [tool-name handler & {:as more}]
(merge {:name tool-name :description (str tool-name " tool") :input-schema schema :handler handler} more))
(deftest definitions-test
(testing "nothing registered, nothing offered"
(is (= [] (model-context/definitions :games))))
(testing "what the LLM is told is MCP's tools/list, in the order of registration, without the handlers"
(model-context/register! [(tool "b" identity) (tool "a" identity)])
(is (= [{:name "b" :description "b tool" :inputSchema schema}
{:name "a" :description "a tool" :inputSchema schema}]
(model-context/definitions :games))))
(testing "a tool without a description has none"
(model-context/clear!)
(model-context/register! [(tool "a" identity :description nil)])
(is (= [{:name "a" :inputSchema schema}] (model-context/definitions :games)))))
(deftest register-test
(testing "registering a name again replaces the tool, so it is safe on hot reload"
(model-context/register! [(tool "a" identity) (tool "b" identity)])
(model-context/register! [(tool "a" identity :description "new") (tool "b" identity)])
(is (= [["a" "new"] ["b" "b tool"]] (map (juxt :name :description) (model-context/definitions :games)))))
(testing "tools of other names stay"
(model-context/register! [(tool "c" identity)])
(is (= ["a" "b" "c"] (map :name (model-context/definitions :games)))))
(testing "clear! takes them all"
(model-context/clear!)
(is (= [] (model-context/definitions :games)))))
(deftest pages-test
(model-context/register! [(tool "everywhere" identity)
(tool "games-only" identity :pages #{:games})
(tool "todos-only" identity :pages #{:todos})
(tool "both" identity :pages #{:games :todos})])
(testing "a tool without pages is offered on every page, the others only on theirs"
(is (= ["everywhere" "games-only" "both"] (map :name (model-context/definitions :games))))
(is (= ["everywhere" "todos-only" "both"] (map :name (model-context/definitions :todos))))
(is (= ["everywhere"] (map :name (model-context/definitions :ai-setup)))))
(testing "the pages are not part of what the LLM is told"
(is (= #{:name :description :inputSchema} (set (mapcat keys (model-context/definitions :games))))))
(testing "a page that is not known, or none, is offered the tools of no page"
(is (= ["everywhere"] (map :name (model-context/definitions nil)))))
(testing "registering a tool again replaces its pages too"
(model-context/register! [(tool "games-only" identity :pages #{:todos})])
(is (= ["everywhere" "both"] (map :name (model-context/definitions :games))))))
(defn- called
"A promise of the result of calling `tool-name` on the JSON text `arguments`."
[tool-name arguments]
(model-context/call! :games tool-name arguments))
(deftest call-test
(let [seen (atom nil)]
(model-context/register!
[(tool "echo" (fn [args] (reset! seen args) (str "got " (pr-str args))))
(tool "data" (fn [_] {:a 1 :b [true nil]}))
(tool "nothing" (fn [_] nil))
(tool "later" (fn [_] (js/Promise.resolve "eventually")))
(tool "throws" (fn [_] (throw (js/Error. "boom"))))
(tool "rejects" (fn [_] (js/Promise.reject (js/Error. "no luck"))))
(tool "rejects-plainly" (fn [_] (js/Promise.reject "just a string")))])
(async done
(tu/finish
(tu/steps
#(called "echo" "{\"x\":1,\"name\":\"a b\",\"nested\":{\"y\":[1,2]}}")
(fn [r]
(testing "the arguments the LLM wrote reach the tool as a map with keyword keys"
(is (= {:x 1 :name "a b" :nested {:y [1 2]}} @seen))
(is (= {:text "got {:x 1, :name \"a b\", :nested {:y [1 2]}}" :error? false} r))))
#(called "echo" "")
(fn [r]
(testing "no arguments at all is an empty map: that is how a model calls a tool that takes none"
(is (= {} @seen))
(is (false? (:error? r)))))
#(called "echo" " ")
(fn [_] (is (= {} @seen) "blank too"))
#(called "echo" "{}")
(fn [_] (is (= {} @seen)))
#(called "data" "{}")
(fn [r]
(testing "data becomes JSON"
(is (= {:text "{\"a\":1,\"b\":[true,null]}" :error? false} r))))
#(called "nothing" "{}")
(fn [r] (is (= {:text "ok" :error? false} r) "a tool with nothing to say says ok"))
#(called "later" "{}")
(fn [r] (is (= {:text "eventually" :error? false} r) "a promise is waited for"))
#(called "throws" "{}")
(fn [r] (is (= {:text "boom" :error? true} r) "a tool that fails is an error result, not a failure"))
#(called "rejects" "{}")
(fn [r] (is (= {:text "no luck" :error? true} r)))
#(called "rejects-plainly" "{}")
(fn [r] (is (= {:text "just a string" :error? true} r))))
done))))
(deftest call-pages-test
(let [ran (atom [])]
(model-context/register! [(tool "games-only" (fn [_] (swap! ran conj :games-only) "played") :pages #{:games})
(tool "everywhere" (fn [_] (swap! ran conj :everywhere) "ran"))])
(async done
(tu/finish
(tu/steps
#(model-context/call! :games "games-only" "{}")
(fn [r] (is (= {:text "played" :error? false} r) "on its page, a tool is called"))
#(model-context/call! :todos "everywhere" "{}")
(fn [r] (is (= {:text "ran" :error? false} r) "a tool of no page is called on any"))
#(model-context/call! :todos "games-only" "{}")
(fn [r]
(testing "a tool of another page is refused, and the LLM is told where it is offered"
(is (= {:text "tool not available on this page: games-only (it is offered on the games page)"
:error? true}
r))))
(fn [_] (is (= [:games-only :everywhere] @ran) "and never run")))
done))))
(deftest call-failures-test
(let [ran? (atom false)]
(model-context/register! [(tool "t" (fn [_] (reset! ran? true) "ran"))])
(async done
(tu/finish
(tu/steps
#(called "nope" "{}")
(fn [r]
(testing "a tool that isn't there: the LLM may have made it up, and reads what it got wrong"
(is (= {:text "unknown tool: nope" :error? true} r))))
#(called "t" "{not json")
(fn [r]
(testing "arguments that are no JSON"
(is (= {:text "invalid arguments: they must be a JSON object" :error? true} r))))
#(called "t" "[1,2]")
(fn [r] (is (:error? r) "an array isn't arguments"))
#(called "t" "\"text\"")
(fn [r] (is (:error? r) "nor is a string"))
#(called "t" "null")
(fn [r] (is (:error? r) "nor is null"))
(fn [_] (is (false? @ran?) "the tool was never called with any of them")))
done))))
+31
View File
@@ -0,0 +1,31 @@
(ns tbd.ui.sse-test
(:require [cljs.test :refer [deftest is testing]]
[tbd.ui.sse :as sse]))
(deftest parse-test
(testing "complete events come out as maps, in order"
(is (= [[{:content "Hel"} {:content "lo"} {:done true}] ""]
(sse/parse "data: {\"content\":\"Hel\"}\n\ndata: {\"content\":\"lo\"}\n\ndata: {\"done\":true}\n\n"))))
(testing "an event that hasn't fully arrived is given back, to be put in front of the next piece"
(is (= [[{:content "a"}] "data: {\"content\":\"b"]
(sse/parse "data: {\"content\":\"a\"}\n\ndata: {\"content\":\"b")))
(is (= [[] "data: {\"content\":\"a\"}\n"]
(sse/parse "data: {\"content\":\"a\"}\n")) "the blank line that ends it hasn't come")
(is (= [[] ""] (sse/parse ""))))
(testing "pieces cut anywhere give the same events"
(let [text "data: {\"thinking\":\"hm\"}\n\ndata: {\"content\":\"h\\u00e9llo\\n\"}\n\ndata: {\"done\":true}\n\n"
expected [{:thinking "hm"} {:content "héllo\n"} {:done true}]]
(doseq [cut (range (inc (count text)))]
(let [[first-events pending] (sse/parse (subs text 0 cut))
[more-events pending] (sse/parse (str pending (subs text cut)))]
(is (= expected (into first-events more-events)) (str "cut at " cut))
(is (= "" pending))))))
(testing "a space after `data:` is optional, and a line break can be CRLF"
(is (= [[{:content "a"} {:content "b"}] ""]
(sse/parse "data:{\"content\":\"a\"}\r\n\r\ndata: {\"content\":\"b\"}\r\n\r\n"))))
(testing "events that carry no data, or no JSON, are skipped"
(is (= [[{:content "a"}] ""]
(sse/parse ": keep-alive\n\nevent: ping\n\ndata: not json\n\ndata: {\"content\":\"a\"}\n\n"))))
(testing "data over several lines is one JSON text"
(is (= [[{:content "a"}] ""]
(sse/parse "data: {\"content\":\ndata: \"a\"}\n\n")))))
+66 -3
View File
@@ -22,12 +22,30 @@
;; --- A fake server ---
(def server
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n, :add-error msg}."
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n, :add-error msg,
:chat-error msg, :chats [the conversations it was sent], :chat-requests [the whole requests],
:chat-scripts [..], :chat-script [..], :resume fn}. Its LLM says \"echo: \" and the last message,
streaming it in pieces. `:chat-script`, if set, is what it streams instead: events (maps, as the
real server sends them) and :pause, where the stream stops until the test calls the `:resume` fn
the server then holds. `:chat-scripts` are scripts for the next requests, one each, first
come first served (a conversation that goes back and forth, as when the LLM uses tools)."
(atom nil))
(defn- respond [status body]
{:status status :body (pr-str body)})
(defn- respond-stream
"A 200 whose body is the server-sent events of `script` (events and :pause)."
[script]
{:status 200 :script script})
(defn- next-chat-script!
"The next of the server's `:chat-scripts`, which it is then rid of, or nil."
[]
(when-let [script (first (:chat-scripts @server))]
(swap! server update :chat-scripts subvec 1)
script))
(defn- route [method uri body]
(let [{:keys [user todos]} @server
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
@@ -41,6 +59,17 @@
(respond 400 {:error error :details {:x ["is wrong"]}})
(respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))})))
(not user) (respond 401 {:error "not logged in"})
(and (= "POST" method) (= "/api/chat" uri))
(if-let [error (:chat-error @server)]
(respond 502 {:error error})
(do (swap! server update :chats (fnil conj []) (:messages body))
(swap! server update :chat-requests (fnil conj []) body)
(respond-stream (or (next-chat-script!)
(:chat-script @server)
[{:thinking "Reading the message."}
{:content "echo: "}
{:content (:content (last (:messages body)))}
{:done true}]))))
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
(and (= "POST" method) (= "/api/todos" uri))
(if (seq (.trim (:title body)))
@@ -60,11 +89,45 @@
(respond 200 {:deleted true}))
:else (respond 404 {:error "not found"}))))
(def ^:private piece-size
"How many bytes of a stream the fake server delivers at a time: few, so events are cut anywhere,
even in the middle of a character, as the network may."
7)
(defn- script-items
"The script's events 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)))))))
script))
(defn- stream-reader
"What `response.body.getReader()` gives for a stream of `script`: `read` resolves with a piece
of bytes at a time, then with done. At a :pause it waits for the fn the server holds as :resume."
[script]
(let [items (atom (script-items script))]
(letfn [(read []
(let [item (first @items)]
(swap! items rest)
(cond
(nil? item) (js/Promise.resolve #js {:done true})
(= :pause item) (js/Promise. (fn [resolve]
(swap! server assoc :resume #(resolve (read)))))
:else (js/Promise.resolve #js {:done false :value item}))))]
#js {:read read})))
(defn fake-fetch [uri opts]
(let [opts (js->clj opts :keywordize-keys true)
{:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
{:keys [status body script]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
(js/Promise.resolve
#js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))})))
#js {:ok (< status 300) :status status
:text (fn [] (js/Promise.resolve body))
:body #js {:getReader (fn [] (stream-reader script))}})))
(def ^:private real-fetch (.-fetch js/globalThis))
+474
View File
@@ -7,6 +7,7 @@
[re-frame.db :as rf-db]
[tbd.ui.db :as db]
[tbd.ui.events :as events]
[tbd.ui.model-context :as model-context]
[tbd.ui.pages :as pages]
[tbd.ui.test-util :as tu :refer [q q-all text shows steps click! type! submit!]]
[tbd.ui.views :as views]
@@ -467,3 +468,476 @@
(is (= "Log in" (text "section p a")))
(is (= "/auth/login" (.getAttribute (q "section p a") "href")))))
done)))
;; --- Chat widget ---
(defn- panel [] (q "[role=dialog]"))
(defn- chat-messages
"What the conversation shows, message by message."
[]
(mapv #(.-textContent %) (q-all "[role=log] > div:not([role=status]):not([data-tool])")))
(defn- chat-tool-lines
"The tool calls and results the conversation shows, line by line."
[]
(mapv #(.-textContent %) (q-all "[role=log] > div[data-tool]")))
(deftest chat-widget-test
;; A green square in the bottom left corner, opposite WebMCP's blue one; it opens the panel.
(async done
(tu/finish
(steps
#(log-in-page! [])
(fn []
(let [square (q "button[aria-label=Chat]")
style (.-style square)]
(is (= "fixed" (.. square -parentElement -style -position)))
(is (= ["20px" "20px"] [(.. square -parentElement -style -left) (.. square -parentElement -style -bottom)])
"bottom left")
(is (= "rgb(40, 167, 69)" (.-backgroundColor style)) "green")
(is (= "false" (.getAttribute square "aria-expanded")))
(is (nil? (panel)) "the panel starts closed")
(click! square)))
#(tu/eventually panel)
(fn [_]
(is (= "true" (.getAttribute (q "button[aria-label=Chat]") "aria-expanded")))
(is (= "Ask me anything." (text "[role=log] p")))
(is (nil? (q "[role=alert]")))
(click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (nil? (panel))))
(fn [_]
(is true "the square closes it again")
(click! (q "button[aria-label=Chat]")))
#(tu/eventually panel)
(fn [_]
(click! (q "button[aria-label=\"Close chat\"]")))
#(tu/eventually (fn [] (nil? (panel)))))
done)))
(deftest chat-conversation-test
(async done
(tu/finish
(steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (q "input[name=message]")))
(fn [input]
(is (true? (.-disabled (q "[role=dialog] button[type=submit]"))) "nothing to send yet")
(type! input "hello")
(tu/eventually (fn [] (false? (.-disabled (q "[role=dialog] button[type=submit]"))))))
(fn [_]
(submit! (q "[role=dialog] form"))
(tu/eventually (fn [] (= ["hello" "echo: hello"] (chat-messages)))))
(fn [_]
(is (= "" (.-value (q "input[name=message]"))) "the input is emptied when sent")
(is (nil? (q "[role=status]")) "no longer thinking")
(is (= ["Clear" "×"] (mapv #(.-textContent %) (q-all "[role=dialog] > div:first-child button"))))
(type! (q "input[name=message]") "again")
(tu/eventually (fn [] (= "again" (.-value (q "input[name=message]"))))))
(fn [_]
(submit! (q "[role=dialog] form"))
(tu/eventually (fn [] (= 4 (count (chat-messages))))))
(fn [_]
(is (= [[{:role "user" :content "hello"}]
[{:role "user" :content "hello"}
{:role "assistant" :content "echo: hello"}
{:role "user" :content "again"}]]
(: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"))))
(fn [_]
(is (empty? (chat-messages)) "cleared")))
done)))
(deftest chat-thinking-test
;; While the reply is on its way the page says so, and the input can't take another message.
(async done
(let [release (atom nil)
fake-fetch (aget js/globalThis "fetch")]
(aset js/globalThis "fetch"
(fn [uri opts]
(if (= "/api/chat" uri)
(js/Promise. (fn [resolve] (reset! release #(resolve (fake-fetch uri opts)))))
(fake-fetch uri opts))))
(tu/finish
(steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (q "input[name=message]")))
(fn [input] (type! input "hello") (tu/eventually #(= "hello" (.-value input))))
(fn [_] (submit! (q "[role=dialog] form")))
#(tu/eventually (fn [] (q "[role=status]")))
(fn [_]
(is (= "Thinking…" (text "[role=status]")))
(is (= ["hello"] (chat-messages)))
(is (true? (.-readOnly (q "input[name=message]"))))
(is (true? (.-disabled (q "[role=dialog] button[type=submit]"))))
(is (nil? (first (filter #(= "Clear" (.-textContent %)) (q-all "[role=dialog] button"))))
"can't start over mid-reply")
(@release))
#(tu/eventually (fn [] (nil? (q "[role=status]"))))
(fn [_]
(is (= ["hello" "echo: hello"] (chat-messages)))
(is (false? (.-readOnly (q "input[name=message]"))))))
done))))
(defn- send-chat!
"Types `text` into the chat's input and sends it; a promise, which resolves once it was sent."
[text]
(tu/steps
(fn [] (tu/eventually (fn [] (q "input[name=message]"))))
(fn [input] (type! input text) (tu/eventually #(= text (.-value input))))
(fn [_] (submit! (q "[role=dialog] form")))))
(defn- resume-stream!
"Lets the fake server's stream go on from its pause (see tu/server)."
[]
(let [resume (:resume @tu/server)]
(swap! tu/server dissoc :resume)
(resume)))
(defn- panel-text
"Everything the chat panel shows, as text."
[]
(.-textContent (panel)))
(deftest chat-streaming-test
;; The reply shows as it arrives: "Thinking…" until the model's first word (however long it
;; reasons, which is never shown), then the reply growing piece by piece, and only at its end can
;; another message be sent.
(async done
(swap! tu/server assoc :chat-script [:pause
{:thinking "Let me "} {:thinking "think."} :pause
{:content "Hel"} {:content "lo"} :pause
{:content ", world"} {:done true}])
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "hi")
#(tu/eventually (fn [] (and (:resume @tu/server) (q "[role=status]"))))
(fn [_]
(is (= "Thinking…" (text "[role=status]")))
(is (= ["hi"] (chat-messages)) "nothing of the reply yet")
(resume-stream!))
#(tu/eventually (fn [] (:resume @tu/server)))
(fn [_]
(is (= "Thinking…" (text "[role=status]")) "still thinking while the model reasons")
(is (not (str/includes? (panel-text) "Let me think.")) "and what it reasons is not shown")
(is (= ["hi"] (chat-messages)) "it hasn't said anything")
(is (true? (.-readOnly (q "input[name=message]"))) "and it isn't over")
(resume-stream!))
#(tu/eventually (fn [] (and (:resume @tu/server) (= ["hi" "Hello"] (chat-messages)))))
(fn [_]
(is (nil? (q "[role=status]")) "the model has begun to answer")
(is (true? (.-readOnly (q "input[name=message]"))) "but it isn't over")
(is (true? (.-disabled (q "[role=dialog] button[type=submit]"))))
(resume-stream!))
#(tu/eventually (fn [] (= ["hi" "Hello, world"] (chat-messages))))
#(tu/eventually (fn [] (false? (.-readOnly (q "input[name=message]")))))
(fn [_]
(is (not (str/includes? (panel-text) "Let me think.")) "the reasoning is not shown after it either")
(is (nil? (q "[role=status]")))
(is (nil? (q "[role=dialog] [role=alert]")))))
done)))
(deftest chat-reasoning-is-never-shown-test
;; Neither while it comes nor in the finished conversation, in any element of the panel.
(async done
(let [thought "secret reasoning about the answer"]
(swap! tu/server assoc :chat-script [{:thinking thought} {:content "Done."} {:done true}])
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "hi")
#(tu/eventually (fn [] (= ["hi" "Done."] (chat-messages))))
(fn [_]
(is (not (str/includes? (panel-text) "secret")))))
done))))
(defn- pointer!
"Fires a pointer event of `type` at `target`, at (`x`, `y`)."
[target type x y]
(.dispatchEvent target (js/window.MouseEvent. type #js {:bubbles true :clientX x :clientY y :button 0})))
(defn- chat-size [] (:size (:chat @rf-db/app-db)))
(defn- drag-to!
"Moves the pointer to (`x`, `y`), and waits for the panel to have `size` by it."
[x y size]
(pointer! js/window "pointermove" x y)
(tu/eventually (fn [] (= size (chat-size)))))
(deftest chat-resize-test
;; The panel is stuck to the bottom left corner, so dragging its top right corner out is what
;; makes it bigger: right for wider, up for higher. Every drag is from the size it began with.
(async done
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually panel)
(fn [_]
(is (= "320px" (.. (panel) -style -width)))
(is (= "256px" (.. (q "[role=log]") -style -height)))
(is (= "Resize chat" (.getAttribute (q "[role=separator]") "aria-label")))
(pointer! (q "[role=separator]") "pointerdown" 300 300))
#(drag-to! 350 260 {:width 370 :height 296})
#(tu/eventually (fn [] (= ["370px" "296px"] [(.. (panel) -style -width) (.. (q "[role=log]") -style -height)])))
(fn [_] (is true "right 50, up 40"))
#(drag-to! 280 380 {:width 300 :height 176})
#(drag-to! -900 900 {:width 260 :height 96})
(fn [_] (is true "not smaller than is usable"))
#(drag-to! 9000 -9000 {:width (- js/window.innerWidth 40) :height (- js/window.innerHeight 220)})
(fn [_] (is true "not bigger than the window"))
#(drag-to! 400 300 {:width 420 :height 256})
(fn [_]
(pointer! js/window "pointerup" 400 300)
(pointer! js/window "pointermove" 900 100)
(js/Promise. (fn [resolve] (js/setTimeout resolve 20))))
(fn [_]
(is (= {:width 420 :height 256} (chat-size)) "once let go, the pointer no longer resizes")
(click! (q "button[aria-label=\"Close chat\"]")))
#(tu/eventually (fn [] (nil? (panel))))
(fn [_] (click! (q "button[aria-label=Chat]")))
#(tu/eventually panel)
(fn [_]
(is (= "420px" (.. (panel) -style -width)) "the size is kept while it is closed")))
done)))
(defn- fake-scroll!
"jsdom doesn't lay anything out: give `el` the sizes of a conversation of 1000px in a window of
100px, and a scroll position that stays where it is put."
[el]
(let [top (atom 0)]
(js/Object.defineProperty el "scrollHeight" #js {:get (fn [] 1000)})
(js/Object.defineProperty el "clientHeight" #js {:get (fn [] 100)})
(js/Object.defineProperty el "scrollTop" #js {:get (fn [] @top) :set (fn [v] (reset! top v))})
top))
(deftest chat-scrollback-test
;; The conversation follows its end while it is scrolled to it, but scrolling up to read back
;; is left alone, even as the reply keeps coming; sending a message goes back to the end.
(async done
(swap! tu/server assoc :chat-script [{:content "one"} :pause
{:content " two"} :pause
{:content " three"} {:done true}])
(let [top (atom nil)
resume! (fn []
(let [resume (:resume @tu/server)]
(swap! tu/server dissoc :resume)
(resume)))]
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (q "[role=log]")))
(fn [log] (reset! top (fake-scroll! log)))
#(send-chat! "hi")
#(tu/eventually (fn [] (and (:resume @tu/server) (= ["hi" "one"] (chat-messages)))))
(fn [_]
(is (= 1000 @@top) "at the end, it follows the end")
(reset! @top 200)
(.dispatchEvent (q "[role=log]") (js/window.Event. "scroll"))
(resume!))
#(tu/eventually (fn [] (= ["hi" "one two"] (chat-messages))))
(fn [_]
(is (= 200 @@top) "scrolled up, the reply coming in doesn't pull it down")
(reset! @top 890)
(.dispatchEvent (q "[role=log]") (js/window.Event. "scroll"))
(resume!))
#(tu/eventually (fn [] (= ["hi" "one two three"] (chat-messages))))
(fn [_]
(is (= 1000 @@top) "back near the end, it follows again")
(reset! @top 200)
(.dispatchEvent (q "[role=log]") (js/window.Event. "scroll")))
#(tu/eventually (fn [] (false? (.-readOnly (q "input[name=message]")))))
#(send-chat! "again")
#(tu/eventually (fn [] (and (:resume @tu/server) (= 1000 @@top))))
(fn [_]
(is (= 1000 @@top) "sending a message goes back to the end, wherever it was")
(resume!))
#(tu/eventually (fn [] (:resume @tu/server)))
(fn [_] (resume!)))
done))))
(deftest chat-tools-test
;; The LLM asks for the page's own tools: the page calls them, so it shows on the page, and the
;; results go back to the LLM for its answer.
(async done
(model-context/register! webmcp/tools)
(swap! tu/server assoc :chat-scripts
[[{:content "I'll add them."}
{:tool-calls [{:id "call_1" :name "add" :arguments "{\"x\":2,\"y\":3}"}]}
{:done true}]
[{:content "2 + 3 is 5."} {:done true}]])
(tu/finish
(-> (tu/steps
#(log-in-page! [])
;; The calculator's tools are offered on the games page.
(fn [] (rf/dispatch-sync [::events/navigate :games]))
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "what is 2 + 3? use the calculator")
#(tu/eventually (fn [] (= ["what is 2 + 3? use the calculator" "I'll add them." "2 + 3 is 5."]
(chat-messages))))
(fn [_]
(testing "the LLM was told about the tools of the page it was on (the games), not all of them"
(is (= (set (map :name (filter #(contains? (or (:pages %) #{:games}) :games) webmcp/tools)))
(set (map :name (:tools (first (:chat-requests @tu/server))))))))
(testing "the page made the call, as a user would: the calculator holds what was typed and its sum"
(is (= {:x "2" :y "3" :result {:status :ok :x "2" :y "3" :sum 5}}
(:calc @rf-db/app-db))))
(testing "the call and its result show in the conversation, between what was said"
(is (= ["Calling add {\"x\":2,\"y\":3}…" "add → {\"x\":2,\"y\":3,\"sum\":5}"]
(chat-tool-lines)))
(testing "the lines are shown whole: wrapped, not cut off"
(doseq [line (q-all "[data-tool]")]
(is (= "pre-wrap" (.. line -style -whiteSpace)))
(is (= "" (.. line -style -textOverflow)))
(is (not= "hidden" (.. line -style -overflow)))
(is (nil? (.getAttribute line "title")) "no tooltip: there is nothing hidden")))
(is (= ["user" "assistant" "tool" "assistant"] (map :role (:messages (:chat @rf-db/app-db))))))
(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))))))
(testing "and it is over: the panel takes messages again"
(is (false? (.-readOnly (q "input[name=message]")))))))
(.finally model-context/clear!))
done)))
(deftest chat-tool-error-test
;; A tool that can't do what it was asked is an error the LLM reads, not the end of the chat.
(async done
(model-context/register! webmcp/tools)
(swap! tu/server assoc :chat-scripts
[[{:tool-calls [{:id "c1" :name "add" :arguments "{\"x\":\"two\",\"y\":3}"}
{:id "c2" :name "made_up" :arguments "{}"}]}
{:done true}]
[{:content "Sorry, that didn't work."} {:done true}]])
(tu/finish
(-> (tu/steps
#(log-in-page! [])
;; The calculator's tools are offered on the games page.
(fn [] (rf/dispatch-sync [::events/navigate :games]))
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "add two and three")
#(tu/eventually (fn [] (= ["add two and three" "Sorry, that didn't work."] (chat-messages))))
(fn [_]
(is (= ["Calling add {\"x\":\"two\",\"y\":3}…" "Calling made_up {}…"
"add failed: invalid arguments: x and y must be numbers"
"made_up failed: unknown tool: made_up"]
(chat-tool-lines)))
(is (= "crimson" (.. (last (q-all "[role=log] > div[data-tool]")) -style -color)) "failures stand out")
(is (= [true true] (map :error? (filter #(= "tool" (:role %)) (:messages (:chat @rf-db/app-db))))))
(is (nil? (q "[role=dialog] [role=alert]")) "it isn't an error of the chat's")
(is (= {:x "" :y "" :result {:status :idle}} (:calc @rf-db/app-db)) "nothing was done")))
(.finally model-context/clear!))
done)))
(deftest chat-stream-failure-test
;; The stream breaks in the middle of the reply: what came stays, with the error under it.
(async done
(swap! tu/server assoc :chat-script [{:thinking "hm"} {:content "Hel"}
{:error "the LLM server took too long to answer"}])
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "hi")
#(tu/eventually (fn [] (q "[role=dialog] [role=alert]")))
(fn [_]
(is (= "the LLM server took too long to answer" (text "[role=dialog] [role=alert]")))
(is (= ["hi" "Hel"] (chat-messages)))
(is (nil? (q "[role=status]")))
(is (= "" (.-value (q "input[name=message]"))))
(is (false? (.-readOnly (q "input[name=message]"))) "another message can be sent")))
done)))
(deftest chat-stream-cut-test
;; The connection drops with no word from the server: the page says so, rather than wait forever.
(async done
(swap! tu/server assoc :chat-script [{:content "Hel"}])
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "hi")
#(tu/eventually (fn [] (q "[role=dialog] [role=alert]")))
(fn [_]
(is (= "the connection was lost" (text "[role=dialog] [role=alert]")))
(is (= ["hi" "Hel"] (chat-messages)))
(is (nil? (q "[role=status]")) "no longer thinking")))
done)))
(deftest chat-text-survives-the-stream-test
;; The fake server sends its bytes in pieces of a few, so this cuts the text mid-character.
(async done
(tu/finish
(tu/steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(send-chat! "héllo 👋 世界")
#(tu/eventually (fn [] (= 2 (count (chat-messages)))))
(fn [_]
(is (= ["héllo 👋 世界" "echo: héllo 👋 世界"] (chat-messages)))))
done)))
(deftest chat-error-test
(async done
(tu/finish
(steps
#(log-in-page! [])
(fn []
(swap! tu/server assoc :chat-error "the LLM server can't be reached")
(click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (q "input[name=message]")))
(fn [input] (type! input "hello") (tu/eventually #(= "hello" (.-value input))))
(fn [_] (submit! (q "[role=dialog] form")))
#(tu/eventually (fn [] (q "[role=dialog] [role=alert]")))
(fn [_]
(is (= "the LLM server can't be reached" (text "[role=dialog] [role=alert]")))
(is (empty? (chat-messages)))
(is (= "hello" (.-value (q "input[name=message]"))) "the message is back in the input, to send again")
(swap! tu/server dissoc :chat-error)
(submit! (q "[role=dialog] form")))
#(tu/eventually (fn [] (= ["hello" "echo: hello"] (chat-messages))))
(fn [_]
(is (nil? (q "[role=dialog] [role=alert]")) "the error goes away")))
done)))
(deftest chat-needs-login-test
(async done
(tu/finish
(steps
#(open-page!)
#(tu/eventually (fn [] (some #{"Log in"} (nav-links))))
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually panel)
(fn [_]
(is (= "Log in to chat." (text "[role=dialog] p")))
(is (= "/auth/login" (.getAttribute (q "[role=dialog] p a") "href")))
(is (nil? (q "input[name=message]")) "no way to type without a login")))
done)))
(deftest chat-forgotten-on-logout-test
(async done
(tu/finish
(steps
#(log-in-page! [])
(fn [] (click! (q "button[aria-label=Chat]")))
#(tu/eventually (fn [] (q "input[name=message]")))
(fn [input] (type! input "secret") (tu/eventually #(= "secret" (.-value input))))
(fn [_] (submit! (q "[role=dialog] form")))
#(tu/eventually (fn [] (= 2 (count (chat-messages)))))
(fn [_] (click! (q "main > p:last-child button")))
#(tu/eventually (fn [] (= "Log in to chat." (text "[role=dialog] p"))))
(fn [_]
(is (empty? (chat-messages)))
(is (= [] (:messages (:chat @rf-db/app-db))) "and the conversation is gone, not just hidden")))
done)))
+65 -7
View File
@@ -164,7 +164,7 @@
(call "get_tic_tac_toe" {})))
(.then (fn [_]
(is (= :ai-setup (page)) "reading changes nothing")
(call "move_tic_tac_toe" {:cell 4})))
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
(.then (fn [_]
(is (= :games (page)))
(rf/dispatch-sync [::events/navigate :ai-setup])
@@ -191,6 +191,56 @@
(is (= :todos (page)))
(done))))))
(deftest navigate
(open-page!)
(async done
(-> (page-loaded)
(.then #(call "navigate" {:page "ai-setup"}))
(.then (fn [r]
(is (= {:text "{\"page\":\"ai-setup\",\"title\":\"AI setup\"}" :error? false} r))
(is (= :ai-setup (page)) "the page is shown, as after a click on its tab")
(is (= "#/ai-setup" js/location.hash) "and the URL follows")
(call "navigate" {:page "games"})))
(.then (fn [r]
(is (false? (:error? r)))
(is (= :games (page)))
(call "navigate" {:page "nowhere"})))
(.then (fn [r]
(is (:error? r))
(is (re-find #"page must be one of games, todos, ai-setup" (:text r)))
(call "navigate" {:page "ai-setup"})))
(.then (fn [_]
(call "navigate" {:page 5})))
(.then (fn [r]
(is (:error? r) "the page is the name of one")
(call "navigate" {})))
(.then (fn [r]
(is (:error? r))
(is (= :ai-setup (page)) "a refused navigation leaves the page where it was")
(done))))))
(deftest navigate-to-a-page-that-needs-a-login
(open-page!)
(async done
(-> (page-loaded)
(.then #(call "navigate" {:page "todos"}))
(.then (fn [r]
(is (:error? r) "the tab isn't there for someone who is not logged in")
(is (re-find #"not logged in" (:text r)))
(is (= :games (page)))
(done))))))
(deftest navigate-to-the-todos-when-logged-in
(tu/log-in!)
(open-page!)
(async done
(-> (page-loaded)
(.then #(call "navigate" {:page "todos"}))
(.then (fn [r]
(is (= {:text "{\"page\":\"todos\",\"title\":\"Todos\"}" :error? false} r))
(is (= :todos (page)))
(done))))))
(deftest a-tool-that-fails-leaves-the-page-alone
(open-page!)
(async done
@@ -268,24 +318,32 @@
(.then (fn [r]
(is (false? (:error? r)))
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
(call "move_tic_tac_toe" {:cell 4})))
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
(.then (fn [r]
;; It pressed the cell, as a user would, and says it is O's turn now.
(is (= :x (get-in @rf-db/app-db [:ttt :board 4])))
(is (= {:board [nil nil nil nil "x" nil nil nil nil] :status "in_progress" :turn "o"}
(json (:text r))))
(call "move_tic_tac_toe" {:cell 4})))
(call "move_tic_tac_toe" {:cell 4 :player "o"})))
(.then (fn [r]
(is (= {:text "cell 4 is taken" :error? true} r))
(is (= :x (get-in @rf-db/app-db [:ttt :board 4])) "the mark on the taken cell stays")
(is (= :o (get-in @rf-db/app-db [:ttt :turn])) "and the refused move does not use up the turn")
(call "move_tic_tac_toe" {:cell 0 :player "x"})))
(.then (fn [r]
(is (= {:text "it is o's turn, not x's" :error? true} r))
(is (:error? r))
(is (re-find #"it is o's turn, not x's" (:text r)))
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
(call "move_tic_tac_toe" {:cell 9})))
(call "move_tic_tac_toe" {:cell 0})))
(.then (fn [r]
(is (:error? r) "the side is required: no moving for whoever's turn it is")
(is (re-find #"player must be" (:text r)))
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
(call "move_tic_tac_toe" {:cell 9 :player "o"})))
(.then (fn [r]
(is (:error? r))
(is (re-find #"0 to 8" (:text r)))
(call "move_tic_tac_toe" {:cell 1.5})))
(call "move_tic_tac_toe" {:cell 1.5 :player "o"})))
(.then (fn [r]
(is (:error? r))
(call "move_tic_tac_toe" {:cell 0 :player "z"})))
@@ -302,7 +360,7 @@
(is (= {:board ["o" "o" nil "x" "x" "x" nil nil nil]
:status "won" :winner "x" :line [3 4 5]}
(json (:text r))))
(call "move_tic_tac_toe" {:cell 8})))
(call "move_tic_tac_toe" {:cell 8 :player "o"})))
(.then (fn [r]
(is (= {:text "the game is over: start a new game" :error? true} r))
(call "new_tic_tac_toe_game" {})))