+3
-2
@@ -1,6 +1,7 @@
|
||||
;; The UI compiles straight into the server's resources dir, so the server picks it up from
|
||||
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
|
||||
{:source-paths ["src" "test"]
|
||||
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`). ../shared holds the
|
||||
;; .cljc the UI and the server both read (the prompts an LLM is given, tbd.agent.prompt).
|
||||
{:source-paths ["src" "test" "../shared"]
|
||||
|
||||
:dependencies
|
||||
[[reagent "2.0.1"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
(def default-db
|
||||
{;; The page being shown (see tbd.ui.pages); it follows the URL's fragment.
|
||||
:page :games
|
||||
:page :tic-tac-toe
|
||||
:calc {:x "" :y ""
|
||||
;; {:status :idle}
|
||||
;; {:status :loading}
|
||||
@@ -35,15 +35,20 @@
|
||||
;; one such call.
|
||||
;; :size is what the panel was dragged to, in px: its :width and the :height of the conversation
|
||||
;; in it (the rest of the panel is the same at any size).
|
||||
;; :tools-expanded? is whether the tool calls and results show whole (wrapped) or, as they do by
|
||||
;; default, each cut to one line ending in "…" when it doesn't fit.
|
||||
;; The other two are for a reply that is still coming or being answered: :pending-calls are the
|
||||
;; calls it has asked for so far, :tool-calls-used how many calls this turn has made.
|
||||
;; :thinking-at is when (ms since the epoch) the last piece of the model's reasoning came, or nil.
|
||||
:chat {:open? false
|
||||
:size {:width 320 :height 256}
|
||||
:tools-expanded? false
|
||||
:input ""
|
||||
:messages []
|
||||
:status :idle
|
||||
:error nil
|
||||
:pending-calls nil
|
||||
:thinking-at nil
|
||||
:tool-calls-used 0}
|
||||
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
|
||||
:ttt {:board tictactoe/new-board
|
||||
|
||||
+48
-13
@@ -74,6 +74,12 @@
|
||||
(fn [cofx _]
|
||||
(assoc cofx :hash js/location.hash)))
|
||||
|
||||
;; Coeffect: the time now, in ms since the epoch.
|
||||
(rf/reg-cofx
|
||||
::now
|
||||
(fn [cofx _]
|
||||
(assoc cofx :now (js/Date.now))))
|
||||
|
||||
;; Effect: show the page the URL names whenever it changes (a link was followed, or Back and
|
||||
;; Forward were pressed). Only one listener is ever installed, however often this runs.
|
||||
(defonce ^:private hash-listener (atom nil))
|
||||
@@ -173,7 +179,7 @@
|
||||
(defn- reset-chat
|
||||
"An empty conversation, in a panel that is as open (or closed), and as big, as it was."
|
||||
[db]
|
||||
(assoc db :chat (merge (:chat db/default-db) (select-keys (:chat db) [:open? :size]))))
|
||||
(assoc db :chat (merge (:chat db/default-db) (select-keys (:chat db) [:open? :size :tools-expanded?]))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::logout-response
|
||||
@@ -315,6 +321,25 @@
|
||||
[messages]
|
||||
(drop-while #(= "tool" (:role %)) (take-last max-sent-messages messages)))
|
||||
|
||||
(def ^:private stale-result
|
||||
"What the LLM is sent in place of a result from before the user's last message."
|
||||
"(out of date: this was read on an earlier turn and the page may have changed since. Call the tool again for the current state.)")
|
||||
|
||||
(defn- without-stale-results
|
||||
"`messages` with the content of the tool results from before the user's last message replaced by
|
||||
`stale-result`. A result is a snapshot of the page when the tool ran, and the user can change the
|
||||
page between messages, by hand or on another page; a model that is still shown the old snapshot
|
||||
goes by it, even when told it is wrong. What it did stays (its calls, its own words), only what
|
||||
it read goes. The results of this turn stay: a chain of calls goes on from the ones before
|
||||
(list, then act on an id)."
|
||||
[messages]
|
||||
(let [last-user (or (last (keep-indexed (fn [i {:keys [role]}] (when (= "user" role) i)) messages)) 0)]
|
||||
(into [] (map-indexed (fn [i message]
|
||||
(cond-> message
|
||||
(and (< i last-user) (= "tool" (:role message)))
|
||||
(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)
|
||||
@@ -322,7 +347,7 @@
|
||||
(defn- chat-request [db messages]
|
||||
(let [tools (model-context/definitions (:page db))]
|
||||
{:uri "/api/chat" :method "POST"
|
||||
:body (cond-> {:messages (mapv wire-message (recent messages))
|
||||
:body (cond-> {:messages (mapv wire-message (without-stale-results (recent messages)))
|
||||
:page (name (:page db))}
|
||||
(seq tools) (assoc :tools tools))
|
||||
:on-event [::chat-event]
|
||||
@@ -340,6 +365,12 @@
|
||||
(fn [db [_ width height]]
|
||||
(assoc-in db [:chat :size] {:width width :height height})))
|
||||
|
||||
;; Whether the tool calls and results show whole, or each cut to one line.
|
||||
(rf/reg-event-db
|
||||
::chat-toggle-tools
|
||||
(fn [db _]
|
||||
(update-in db [:chat :tools-expanded?] not)))
|
||||
|
||||
(rf/reg-event-db
|
||||
::chat-set-input
|
||||
(fn [db [_ input]]
|
||||
@@ -417,7 +448,7 @@
|
||||
[db]
|
||||
(let [{:keys [pending-calls tool-calls-used]} (:chat db)
|
||||
used (+ (or tool-calls-used 0) (count pending-calls))
|
||||
db (assoc-in db [:chat :pending-calls] nil)]
|
||||
db (update db :chat assoc :pending-calls nil :thinking-at nil)]
|
||||
(cond
|
||||
(empty? pending-calls)
|
||||
{:db (assoc-in db [:chat :status] :idle)}
|
||||
@@ -444,21 +475,25 @@
|
||||
|
||||
;; 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), {:content "..."} for the next piece of the reply (the first starts the
|
||||
;; 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.
|
||||
(rf/reg-event-fx
|
||||
::chat-event
|
||||
(fn [{:keys [db]} [_ {:keys [content tool-calls done error]}]]
|
||||
(cond
|
||||
(not= :loading (get-in db [:chat :status])) {: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)}
|
||||
:else {:db db})))
|
||||
[(rf/inject-cofx ::now)]
|
||||
(fn [{:keys [db now]} [_ {:keys [thinking content tool-calls done error]}]]
|
||||
(let [loading? (= :loading (get-in db [:chat :status]))
|
||||
db (cond-> db (and loading? (text? thinking)) (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)}
|
||||
:else {:db db}))))
|
||||
|
||||
(defn- call-all
|
||||
"A promise of the results of `calls`, in order, as {:id :name :content :error?}. One after the
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
A tool is {:name :description :input-schema :handler :pages}: `handler` takes its arguments as a
|
||||
map and returns a value or a promise of one. A string is the result as it is, any other data
|
||||
becomes JSON, and throwing (or rejecting) is an error result the agent can read. `pages` is the
|
||||
set of pages (see tbd.ui.pages) the tool is offered on, so the LLM on the games page is not
|
||||
set of pages (see tbd.ui.pages) the tool is offered on, so the LLM on the tic-tac-toe page is not
|
||||
handed the todos' tools; a tool without it is offered on every page. What is offered follows the
|
||||
page being shown: `definitions` and `call!` both take it, and a tool that is not offered on it
|
||||
can neither be listed nor called."
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
(def pages
|
||||
"The pages in the order of the tabs: {:id :title :login?}. :login? pages are only offered
|
||||
(and only show anything) to someone who is logged in."
|
||||
[{:id :games :title "Games"}
|
||||
[{:id :tic-tac-toe :title "Tic-tac-toe"}
|
||||
{:id :calculator :title "Calculator"}
|
||||
{:id :todos :title "Todos" :login? true}
|
||||
{:id :ai-setup :title "AI setup"}])
|
||||
|
||||
|
||||
+52
-17
@@ -1,5 +1,6 @@
|
||||
(ns tbd.ui.views
|
||||
(:require [clojure.string :as str]
|
||||
[reagent.core :as r]
|
||||
[re-frame.core :as rf]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.pages :as pages]
|
||||
@@ -188,13 +189,17 @@
|
||||
|
||||
(defn- chat-tool-line
|
||||
"A tool the assistant calls, or what came of it, apart from what is said, and in smaller print.
|
||||
All of it shows, wrapped: the conversation scrolls, so it needn't be cut short."
|
||||
[text {:keys [error?]}]
|
||||
[:div {:data-tool true
|
||||
:style {:align-self "flex-start" :max-width "85%"
|
||||
:white-space "pre-wrap" :overflow-wrap "anywhere"
|
||||
:font-family "monospace" :font-size "12px"
|
||||
:color (if error? "crimson" "#777")}}
|
||||
One line, ending in \"…\" where it doesn't fit, unless `expanded?`: then all of it shows, wrapped."
|
||||
[expanded? text {:keys [error?]}]
|
||||
[:div (cond-> {:data-tool true
|
||||
:style (merge {:align-self "flex-start" :max-width "85%" :flex-shrink 0
|
||||
:overflow-wrap "anywhere"
|
||||
:font-family "monospace" :font-size "12px"
|
||||
:color (if error? "crimson" "#777")}
|
||||
(if expanded?
|
||||
{:white-space "pre-wrap"}
|
||||
{:white-space "nowrap" :overflow "hidden" :text-overflow "ellipsis"}))}
|
||||
(not expanded?) (assoc :title text))
|
||||
text])
|
||||
|
||||
(defn- call-text [{:keys [name arguments]}]
|
||||
@@ -206,13 +211,18 @@
|
||||
(defn- chat-message
|
||||
"What was said, as a bubble, and the tool calls the assistant made with it, and their results,
|
||||
as lines between the bubbles."
|
||||
[{:keys [role content tool-calls] :as message}]
|
||||
[tools-expanded? {:keys [role content tool-calls] :as message}]
|
||||
(if (= "tool" role)
|
||||
[chat-tool-line (result-text message) message]
|
||||
[chat-tool-line tools-expanded? (result-text message) message]
|
||||
[:<>
|
||||
(when-not (str/blank? content) [chat-bubble message])
|
||||
(for [call tool-calls]
|
||||
^{:key (:id call)} [chat-tool-line (call-text call) nil])]))
|
||||
^{:key (:id call)} [chat-tool-line tools-expanded? (call-text call) nil])]))
|
||||
|
||||
(defn- tool-use?
|
||||
"Whether the conversation has any tool call or result."
|
||||
[messages]
|
||||
(some #(or (= "tool" (:role %)) (seq (:tool-calls %))) messages))
|
||||
|
||||
(def ^:private chat-min-width 260)
|
||||
(def ^:private chat-min-height 96)
|
||||
@@ -227,16 +237,37 @@
|
||||
(defn- at-end? [el]
|
||||
(<= (- (.-scrollHeight el) (.-scrollTop el) (.-clientHeight el)) stuck-slack))
|
||||
|
||||
(def ^:private thinking-live-ms
|
||||
"How recently (ms) a piece of the model's reasoning must have come for it to count as still
|
||||
coming in."
|
||||
250)
|
||||
(def ^:private thinking-frame-ms 250)
|
||||
(def ^:private thinking-tick-ms 100)
|
||||
|
||||
(defn- thinking-line
|
||||
"\"Thinking…\" which, while pieces of the model's reasoning are still coming in (the last, at
|
||||
`thinking-at`, was less than `thinking-live-ms` ago), cycles through one to three dots."
|
||||
[thinking-at]
|
||||
(r/with-let [clock (r/atom (js/Date.now))
|
||||
timer (js/setInterval #(reset! clock (js/Date.now)) thinking-tick-ms)]
|
||||
(let [now @clock
|
||||
live? (and thinking-at (< (- now thinking-at) thinking-live-ms))]
|
||||
[:div {:role "status" :style {:color "#777"}}
|
||||
(if live?
|
||||
(str "Thinking" (apply str (repeat (inc (mod (quot now thinking-frame-ms) 3)) ".")))
|
||||
"Thinking…")])
|
||||
(finally (js/clearInterval timer))))
|
||||
|
||||
(defn- chat-log
|
||||
"The conversation, which follows its end (as the pieces of a reply come in) for as long as it is
|
||||
scrolled to it. Scroll up to read back and it stays where it is, until you send a message or
|
||||
scroll down to the end again (a message sent is among the ones that came since the last render,
|
||||
which may also hold the first of the reply). \"Thinking…\" shows from the sending until the reply's first piece;
|
||||
what the model reasons is not shown."
|
||||
which may also hold the first of the reply). \"Thinking…\" shows from the sending until the reply's first piece
|
||||
(animated while the model's reasoning is coming in); what the model reasons is not shown."
|
||||
[_chat _size]
|
||||
(let [stuck? (atom true)
|
||||
seen (atom 0)]
|
||||
(fn [{:keys [messages status]} {:keys [height]}]
|
||||
(fn [{:keys [messages status thinking-at tools-expanded?]} {:keys [height]}]
|
||||
[:div {:role "log" :aria-live "polite"
|
||||
:ref (fn [el]
|
||||
(when el
|
||||
@@ -252,9 +283,9 @@
|
||||
(if (and (empty? messages) (not= :loading status))
|
||||
[:p {:style {:color "#777" :margin 0}} "Ask me anything."]
|
||||
(for [[i message] (map-indexed vector messages)]
|
||||
^{:key i} [chat-message message]))
|
||||
^{:key i} [chat-message tools-expanded? message]))
|
||||
(when (and (= :loading status) (not= "assistant" (:role (peek messages))))
|
||||
[:div {:role "status" :style {:color "#777"}} "Thinking…"])])))
|
||||
[thinking-line thinking-at])])))
|
||||
|
||||
(defn- chat-form [{:keys [input status]}]
|
||||
(let [loading? (= :loading status)]
|
||||
@@ -313,7 +344,7 @@
|
||||
:cursor "nesw-resize" :touch-action "none"
|
||||
:background "linear-gradient(225deg, #bbb 0 2px, transparent 2px 5px, #bbb 5px 7px, transparent 7px)"}}])
|
||||
|
||||
(defn- chat-panel [{:keys [messages status size] :as chat} user]
|
||||
(defn- chat-panel [{:keys [messages status size tools-expanded?] :as chat} user]
|
||||
[:div {:role "dialog" :aria-label "Chat"
|
||||
:style {:position "absolute" :bottom "40px" :left 0 :box-sizing "border-box"
|
||||
:width (str (:width size) "px")
|
||||
@@ -322,6 +353,9 @@
|
||||
:box-shadow "0 5px 15px rgba(0,0,0,0.1)"}}
|
||||
[:div {:style {:display "flex" :align-items "center" :gap "0.5rem"}}
|
||||
[:strong {:style {:flex 1}} "Chat"]
|
||||
(when (tool-use? messages)
|
||||
[link-button {:on-click #(rf/dispatch [::events/chat-toggle-tools])}
|
||||
(if tools-expanded? "Collapse tool use" "Expand tool use")])
|
||||
(when (and (seq messages) (not= :loading status))
|
||||
[link-button {:on-click #(rf/dispatch [::events/chat-clear])} "Clear"])
|
||||
[:button {:type "button" :aria-label "Close chat"
|
||||
@@ -350,7 +384,8 @@
|
||||
|
||||
(defn- current-page [page user]
|
||||
(case page
|
||||
:games [:<> [tic-tac-toe] [calculator]]
|
||||
:tic-tac-toe [tic-tac-toe]
|
||||
:calculator [calculator]
|
||||
:todos (if (= :logged-in (:status user)) [todos] [login-prompt user])
|
||||
:ai-setup [:<> [mcp-setup] [browser-tools]]))
|
||||
|
||||
|
||||
+215
-66
@@ -7,6 +7,12 @@
|
||||
the form, press a button, tick a box) by dispatching the same re-frame events the views do, so
|
||||
everything shows on screen, and reads what the page shows from the app-db.
|
||||
|
||||
Besides the tools, the page registers prompts and resources (`prompts`, `resources`), which is
|
||||
what an MCP client lists next to the tools (Claude Code shows the prompts as slash commands and
|
||||
the resources as @-mentions). They are the same instructions the page's own chatbot is given
|
||||
(tbd.agent.prompt, shared with the server), so Claude or Codex is told to act rather than offer,
|
||||
to chain calls and to play tic-tac-toe by the turn, and can read what page is open.
|
||||
|
||||
The same tools are registered with tbd.ui.model-context (by tbd.ui.core), which is how the page's
|
||||
own chatbot offers them to its LLM and calls them, the ones of the page being shown (see
|
||||
`tools`): this ns says what the tools are, and has the WebMCP way of offering them all to an
|
||||
@@ -19,6 +25,7 @@
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.agent.prompt :as prompt]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.model-context :as model-context]
|
||||
[tbd.ui.pages :as pages]
|
||||
@@ -106,13 +113,41 @@
|
||||
(first (filter #(= id (:id %)) items)))
|
||||
|
||||
(defn- find-todo! [items id]
|
||||
(or (find-todo items id) (fail! "todo not found")))
|
||||
(or (find-todo items id) (fail! (str "todo not found: " id))))
|
||||
|
||||
;; --- Argument checks (the page can't trust that the agent honours the schema) ---
|
||||
|
||||
(defn- check-id! [id]
|
||||
(when-not (and (number? id) (js/Number.isInteger id))
|
||||
(fail! "invalid arguments: id must be an integer")))
|
||||
(when-not (and (string? id) (seq id))
|
||||
(fail! "invalid arguments: id must be a todo's id, the UUID list_todos gives")))
|
||||
|
||||
(defn- check-todos!
|
||||
"Checks the `todos` argument of a todo tool, which takes its work as a list (one call for a
|
||||
whole batch, not one call per todo): a non-empty list of maps that `check-item!` accepts."
|
||||
[todos check-item!]
|
||||
(when-not (and (sequential? todos) (seq todos) (every? map? todos))
|
||||
(fail! "invalid arguments: todos must be a non-empty list of objects"))
|
||||
(run! check-item! todos))
|
||||
|
||||
(defn- in-order
|
||||
"A promise of the vector of `(f item)` (a promise) for each of `items`, run one at a time: a
|
||||
request finishes before the next starts, so a failure is one item's. When one fails after
|
||||
others were done, the error says how many were."
|
||||
[f items]
|
||||
(let [total (count items)]
|
||||
(reduce (fn [so-far item]
|
||||
(.then so-far
|
||||
(fn [results]
|
||||
(-> (js/Promise.resolve item)
|
||||
(.then f)
|
||||
(.then #(conj results %))
|
||||
(.catch (fn [e]
|
||||
(fail! (if (seq results)
|
||||
(str (.-message e) " (the first " (count results) " of "
|
||||
total " were done)")
|
||||
(.-message e)))))))))
|
||||
(js/Promise.resolve [])
|
||||
items)))
|
||||
|
||||
;; --- The tools ---
|
||||
|
||||
@@ -149,7 +184,7 @@
|
||||
(fail! "invalid arguments: x and y must be numbers"))
|
||||
(when (= :loading (get-in (db) [:calc :result :status]))
|
||||
(fail! "an addition is already in progress"))
|
||||
(show! :games)
|
||||
(show! :calculator)
|
||||
(rf/dispatch-sync [::events/set-operand :x (str x)])
|
||||
(rf/dispatch-sync [::events/set-operand :y (str y)])
|
||||
(rf/dispatch-sync [::events/add])
|
||||
@@ -163,53 +198,76 @@
|
||||
(defn- list-todos [_]
|
||||
(logged-in-todos))
|
||||
|
||||
(defn- get-todo [{:keys [id]}]
|
||||
(check-id! id)
|
||||
(defn- get-todo
|
||||
"Reads todos by id, all of them or none."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos #(check-id! (:id %)))
|
||||
(-> (logged-in-todos)
|
||||
(.then #(find-todo! % id))))
|
||||
(.then (fn [items] (mapv #(find-todo! items (:id %)) todos)))))
|
||||
|
||||
(defn- create-todo
|
||||
"Types the title into the new-todo field and presses Add."
|
||||
(defn- create-one
|
||||
"Types the title into the new-todo field and presses Add. A promise of the todo that was created."
|
||||
[{:keys [title]}]
|
||||
(when-not (and (string? title) (seq title))
|
||||
(fail! "invalid arguments: title must be a non-empty string"))
|
||||
(-> (logged-in-todos)
|
||||
(.then (fn [_]
|
||||
(show! :todos)
|
||||
(rf/dispatch-sync [::events/set-new-title title])
|
||||
;; The field is emptied when the todo was added; a failure leaves it as it is.
|
||||
(change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")))
|
||||
(show! :todos)
|
||||
(rf/dispatch-sync [::events/set-new-title title])
|
||||
;; The field is emptied when the todo was added; a failure leaves it as it is.
|
||||
(-> (change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")
|
||||
(.then #(last (get-in % [:todos :items])))))
|
||||
|
||||
(defn- set-todo-done
|
||||
"Ticks or unticks a todo's checkbox."
|
||||
(defn- create-todo
|
||||
"Adds todos, one after the other: a promise of the todos that were created."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos (fn [{:keys [title]}]
|
||||
(when-not (and (string? title) (seq title))
|
||||
(fail! "invalid arguments: title must be a non-empty string"))))
|
||||
(-> (logged-in-todos)
|
||||
(.then (fn [_] (in-order create-one todos)))))
|
||||
|
||||
(defn- set-one-done
|
||||
"Ticks or unticks one todo's checkbox, unless it is already so (no request then). A promise of
|
||||
the todo as the page shows it."
|
||||
[{:keys [id done]}]
|
||||
(check-id! id)
|
||||
(when-not (boolean? done)
|
||||
(fail! "invalid arguments: done must be true or false"))
|
||||
(let [todo (find-todo! (get-in (db) [:todos :items]) id)]
|
||||
(if (= done (boolean (:done todo)))
|
||||
(js/Promise.resolve todo)
|
||||
(-> (change-todos [::events/set-done id done]
|
||||
#(= done (boolean (:done (find-todo (get-in % [:todos :items]) id))))
|
||||
"the todo")
|
||||
(.then #(find-todo (get-in % [:todos :items]) id))))))
|
||||
|
||||
(defn- set-todo-done
|
||||
"Ticks or unticks the checkboxes of several todos. Every change is checked before the first is
|
||||
made. A promise of the todos as they are then."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos (fn [{:keys [id done]}]
|
||||
(check-id! id)
|
||||
(when-not (boolean? done)
|
||||
(fail! "invalid arguments: done must be true or false"))))
|
||||
(-> (logged-in-todos)
|
||||
(.then (fn [items]
|
||||
(let [todo (find-todo! items id)]
|
||||
(show! :todos)
|
||||
(if (= done (boolean (:done todo)))
|
||||
todo
|
||||
(-> (change-todos [::events/set-done id done]
|
||||
#(= done (boolean (:done (find-todo (get-in % [:todos :items]) id))))
|
||||
"the todo")
|
||||
(.then #(find-todo (get-in % [:todos :items]) id)))))))))
|
||||
(run! #(find-todo! items (:id %)) todos)
|
||||
(show! :todos)
|
||||
(in-order set-one-done todos)))))
|
||||
|
||||
(defn- delete-one
|
||||
"Presses a todo's Delete button. A promise of its id."
|
||||
[id]
|
||||
(-> (change-todos [::events/delete-todo id]
|
||||
#(nil? (find-todo (get-in % [:todos :items]) id))
|
||||
"the todo to go")
|
||||
(.then (fn [_] id))))
|
||||
|
||||
(defn- delete-todo
|
||||
"Presses a todo's Delete button."
|
||||
[{:keys [id]}]
|
||||
(check-id! id)
|
||||
(-> (logged-in-todos)
|
||||
(.then (fn [items]
|
||||
(find-todo! items id)
|
||||
(show! :todos)
|
||||
(change-todos [::events/delete-todo id]
|
||||
#(nil? (find-todo (get-in % [:todos :items]) id))
|
||||
"the todo to go")))
|
||||
(.then (fn [_] "deleted"))))
|
||||
"Presses the Delete button of several todos. Every id is looked up before the first is deleted."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos #(check-id! (:id %)))
|
||||
(let [ids (distinct (map :id todos))]
|
||||
(-> (logged-in-todos)
|
||||
(.then (fn [items]
|
||||
(run! #(find-todo! items %) ids)
|
||||
(show! :todos)
|
||||
(in-order delete-one ids)))
|
||||
(.then (fn [deleted] {:deleted deleted})))))
|
||||
|
||||
;; Tic-tac-toe is moves taken in turns: X, then O, on the same board. A person plays on the same
|
||||
;; board, so a move names the side it is for, and is refused when it is not that side's turn: an
|
||||
@@ -245,21 +303,29 @@
|
||||
(fail! (str "it is " (name turn) "'s turn, not " player "'s: wait for the other side to move")))
|
||||
(when (board cell)
|
||||
(fail! (str "cell " cell " is taken")))
|
||||
(show! :games)
|
||||
(show! :tic-tac-toe)
|
||||
(rf/dispatch-sync [::events/ttt-move cell])
|
||||
(tic-tac-toe-state)))
|
||||
|
||||
(defn- new-tic-tac-toe-game
|
||||
"Presses New game."
|
||||
[_]
|
||||
(show! :games)
|
||||
(show! :tic-tac-toe)
|
||||
(rf/dispatch-sync [::events/ttt-reset])
|
||||
(tic-tac-toe-state))
|
||||
|
||||
(defn- object-schema [properties required]
|
||||
{:type "object" :properties properties :required required})
|
||||
|
||||
(def ^:private todo-id {:type "integer" :description "The todo's id"})
|
||||
(def ^:private todo-id {:type "string" :description "The todo's id, a UUID as list_todos gives it"})
|
||||
|
||||
(defn- todos-schema
|
||||
"The `todos` argument of the todo tools: a list of objects, one per todo the call is about, so
|
||||
that a batch is one call."
|
||||
[description properties required]
|
||||
(object-schema {:todos {:type "array" :minItems 1 :description description
|
||||
:items (object-schema properties required)}}
|
||||
["todos"]))
|
||||
|
||||
(def tools
|
||||
"The tools, as {:name :description :input-schema :handler :pages}. `pages` is where the page's
|
||||
@@ -286,12 +352,12 @@
|
||||
["page"])
|
||||
:handler navigate}
|
||||
|
||||
{:name "get_calculator" :pages #{:games}
|
||||
{:name "get_calculator" :pages #{:calculator}
|
||||
:description (str "What the calculator form on this page holds (JSON): the x and y in its fields, and "
|
||||
"the result it is showing (status idle, loading, ok with the sum, or error with the message)")
|
||||
:input-schema (object-schema {} []) :handler get-calculator}
|
||||
|
||||
{:name "add" :pages #{:games}
|
||||
{:name "add" :pages #{:calculator}
|
||||
:description (str "Adds two numbers: types them into the calculator form, presses Add, and returns the sum "
|
||||
"the page shows (JSON). Only for arithmetic, not for adding items to a list")
|
||||
:input-schema (object-schema {:x {:type "number" :description "The first number"}
|
||||
@@ -305,30 +371,38 @@
|
||||
:input-schema (object-schema {} []) :handler list-todos}
|
||||
|
||||
{:name "get_todo" :pages #{:todos}
|
||||
:description "Gets one of the todos this page shows, by id (JSON)"
|
||||
:input-schema (object-schema {:id todo-id} ["id"]) :handler get-todo}
|
||||
:description (str "Gets todos this page shows, by id (JSON list). Takes a list: to read several, make ONE call "
|
||||
"with all of them")
|
||||
:input-schema (todos-schema "The todos to get, one per todo" {:id todo-id} ["id"])
|
||||
:handler get-todo}
|
||||
|
||||
{:name "create_todo" :pages #{:todos}
|
||||
:description "Types a title into the new-todo field, presses Add, and returns the todo that was created (JSON)"
|
||||
:input-schema (object-schema {:title {:type "string" :minLength 1 :maxLength 200
|
||||
:description "What needs doing"}}
|
||||
["title"])
|
||||
:description (str "Adds todos: types each title into the new-todo field and presses Add, and returns the todos "
|
||||
"that were created (JSON list). Takes a list: to add several, make ONE call with all of them, "
|
||||
"never one call per todo")
|
||||
:input-schema (todos-schema "The todos to add, one per todo"
|
||||
{:title {:type "string" :minLength 1 :maxLength 200 :description "What needs doing"}}
|
||||
["title"])
|
||||
:handler create-todo}
|
||||
|
||||
{:name "set_todo_done" :pages #{:todos}
|
||||
:description "Ticks or unticks a todo's checkbox and returns the todo (JSON)"
|
||||
:input-schema (object-schema {:id todo-id
|
||||
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
|
||||
["id" "done"])
|
||||
:description (str "Ticks or unticks the checkboxes of todos, and returns those todos (JSON list). Takes a list: "
|
||||
"to change several, make ONE call with all of them, never one call per todo")
|
||||
:input-schema (todos-schema "The changes to make, one per todo"
|
||||
{:id todo-id
|
||||
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
|
||||
["id" "done"])
|
||||
:handler set-todo-done}
|
||||
|
||||
{:name "delete_todo" :pages #{:todos}
|
||||
:description (str "Deletes a todo for good, by pressing its Delete button. Find the id with list_todos first; "
|
||||
"never guess one, and never delete a todo the user did not clearly point at")
|
||||
:input-schema (object-schema {:id todo-id} ["id"])
|
||||
:description (str "Deletes todos for good, by pressing their Delete buttons, and returns the ids that were "
|
||||
"deleted (JSON). Find the ids with list_todos first; never guess one, and never delete a "
|
||||
"todo the user did not clearly point at. Takes a list: to delete several, make ONE call "
|
||||
"with all of them")
|
||||
:input-schema (todos-schema "The todos to delete, one per todo" {:id todo-id} ["id"])
|
||||
:handler delete-todo}
|
||||
|
||||
{:name "get_tic_tac_toe" :pages #{:games}
|
||||
{:name "get_tic_tac_toe" :pages #{:tic-tac-toe}
|
||||
:description (str "The tic-tac-toe game on this page (JSON): board (9 cells, row by row, cell 0 the top left "
|
||||
"and cell 8 the bottom right; null when empty, else \"x\" or \"o\"), turn (whose move it "
|
||||
"is, \"x\" or \"o\": X goes first, and they alternate) and status (in_progress, won or draw; "
|
||||
@@ -336,7 +410,7 @@
|
||||
"before you move, and only move for your own side")
|
||||
:input-schema (object-schema {} []) :handler get-tic-tac-toe}
|
||||
|
||||
{:name "move_tic_tac_toe" :pages #{:games}
|
||||
{:name "move_tic_tac_toe" :pages #{:tic-tac-toe}
|
||||
:description (str "Presses a cell of the tic-tac-toe board as `player`, your side, and returns the game as "
|
||||
"get_tic_tac_toe does (including whose turn is next). Fails, and changes nothing, if it "
|
||||
"is not your side's turn (the other side is the person's: wait for them to move), the "
|
||||
@@ -349,10 +423,55 @@
|
||||
["cell" "player"])
|
||||
:handler move-tic-tac-toe}
|
||||
|
||||
{:name "new_tic_tac_toe_game" :pages #{:games}
|
||||
{:name "new_tic_tac_toe_game" :pages #{:tic-tac-toe}
|
||||
:description "Presses New game: clears the tic-tac-toe board, X to move (JSON, as get_tic_tac_toe)"
|
||||
:input-schema (object-schema {} []) :handler new-tic-tac-toe-game}])
|
||||
|
||||
;; --- Prompts and resources ---
|
||||
|
||||
(def prompts
|
||||
"The prompts an MCP client can start from, as {:name :description :arguments :handler}: `handler`
|
||||
takes the arguments the client gave (strings, or nothing) and returns the prompt's text.
|
||||
`arguments` is [{:name :description :required}], as MCP's `prompts/list`."
|
||||
[{:name "drive_the_app"
|
||||
:description (str "How to work this app's page: act in the same turn instead of offering, chain the "
|
||||
"tools, report in a sentence. Optionally hands over what you want done")
|
||||
:arguments [{:name "task" :required false
|
||||
:description "What you want done in the app, e.g. \"add a todo to buy milk\""}]
|
||||
:handler (fn [{:keys [task]}] (prompt/task task))}
|
||||
|
||||
{:name "play_tic_tac_toe"
|
||||
:description "Plays tic-tac-toe with you on the page: moves only on its own side's turn, one move at a time"
|
||||
:arguments [{:name "side" :required false
|
||||
:description "The side the agent plays, \"x\" or \"o\" (default o: you play X and start)"}]
|
||||
:handler (fn [{:keys [side]}] (prompt/tic-tac-toe side))}])
|
||||
|
||||
(defn- page-state
|
||||
"A promise of what the page is showing: the open page, the pages and who is logged in."
|
||||
[]
|
||||
(-> (known-user)
|
||||
(.then (fn [{:keys [status login message]}]
|
||||
{:page (name (:page (db)))
|
||||
:pages (mapv (fn [{:keys [id title login?]}]
|
||||
(cond-> {:id (name id) :title title} login? (assoc :needs-login true)))
|
||||
pages/pages)
|
||||
:user (case status
|
||||
:logged-in {:logged-in true :login login}
|
||||
:anonymous {:logged-in false}
|
||||
:error {:error message})}))))
|
||||
|
||||
(def resources
|
||||
"The resources an MCP client can read, as {:name :description :uri :mime-type :provide}:
|
||||
`provide` returns the text, or a promise of it."
|
||||
[{:name "app_guide" :uri "webmcp://app/guide" :mime-type "text/markdown"
|
||||
:description (str "Read this first: what the page's tools are for and how to use them (act, don't "
|
||||
"offer; chain the calls; report briefly)")
|
||||
:provide (fn [] prompt/guide)}
|
||||
|
||||
{:name "app_state" :uri "webmcp://app/state" :mime-type "application/json"
|
||||
:description "Which page is open now, the pages of the app, and whether anyone is logged in (JSON)"
|
||||
:provide (fn [] (-> (page-state) (.then #(js/JSON.stringify (clj->js %) nil 2))))}])
|
||||
|
||||
;; --- Registering them with WebMCP ---
|
||||
|
||||
(defn- text-result [text error?]
|
||||
@@ -380,11 +499,41 @@
|
||||
(js-invoke mcp "registerTool" name description (clj->js input-schema) (execute handler)))
|
||||
mcp)
|
||||
|
||||
(defn- prompt-result
|
||||
"MCP's `prompts/get` result: the prompt's text as the user's first message."
|
||||
[description text]
|
||||
#js {"description" description
|
||||
"messages" #js [#js {"role" "user" "content" #js {"type" "text" "text" text}}]})
|
||||
|
||||
(defn register-prompts!
|
||||
"Registers `prompts` on `mcp`: `registerPrompt(name, description, arguments, execute)`. Safe to
|
||||
call again, like `register-tools!`."
|
||||
[mcp]
|
||||
(doseq [{:keys [name description arguments handler]} prompts]
|
||||
(js-invoke mcp "registerPrompt" name description (clj->js arguments)
|
||||
(fn [args] (prompt-result description (handler (js->clj args :keywordize-keys true))))))
|
||||
mcp)
|
||||
|
||||
(defn register-resources!
|
||||
"Registers `resources` on `mcp`: `registerResource(name, description, {uri, mimeType}, provide)`,
|
||||
where `provide(uri)` gives MCP's `resources/read` result. Safe to call again."
|
||||
[mcp]
|
||||
(doseq [{:keys [name description uri mime-type provide]} resources]
|
||||
(js-invoke mcp "registerResource" name description #js {"uri" uri "mimeType" mime-type}
|
||||
(fn [requested]
|
||||
(-> (js/Promise.resolve (provide))
|
||||
(.then (fn [text]
|
||||
#js {"contents" #js [#js {"uri" requested "mimeType" mime-type "text" text}]}))))))
|
||||
mcp)
|
||||
|
||||
(defonce ^:private widget (atom nil))
|
||||
|
||||
(defn register!
|
||||
"Offers `tools` to the agent through WebMCP. The first call creates the WebMCP widget (the small
|
||||
square in the corner of the page, where the user pastes the token from their MCP client), later
|
||||
ones only register the tools again. Returns the WebMCP instance."
|
||||
"Offers `tools`, `prompts` and `resources` to the agent through WebMCP. The first call creates
|
||||
the WebMCP widget (the small square in the corner of the page, where the user pastes the token
|
||||
from their MCP client), later ones only register them again. Returns the WebMCP instance."
|
||||
[]
|
||||
(register-tools! (or @widget (reset! widget (WebMCP.)))))
|
||||
(-> (or @widget (reset! widget (WebMCP.)))
|
||||
register-tools!
|
||||
register-prompts!
|
||||
register-resources!))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns tbd.ui.events-test
|
||||
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.ui.db :as db]
|
||||
@@ -62,8 +63,8 @@
|
||||
(reset! effects [])
|
||||
(dispatch! [::events/initialize]))
|
||||
|
||||
(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done false :created-at 2 :updated-at 2})
|
||||
(def ^:private todo-1 {:id (tu/todo-uuid 1) :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id (tu/todo-uuid 2) :owner-id 7 :title "walk dog" :done false :created-at 2 :updated-at 2})
|
||||
|
||||
(defn- logged-in!
|
||||
"The page is up, alice is logged in and her todos (`items`) are shown."
|
||||
@@ -94,7 +95,7 @@
|
||||
(dispatch! [::events/hash-changed "#/todos"])
|
||||
(is (= :todos (:page (app-db))))
|
||||
(dispatch! [::events/hash-changed "#/nowhere"])
|
||||
(is (= :games (:page (app-db))) "an unknown address shows the first page")
|
||||
(is (= :tic-tac-toe (:page (app-db))) "an unknown address shows the first page")
|
||||
(is (= [] (requests)) "changing the page asks the server for nothing"))
|
||||
|
||||
(deftest navigate-test
|
||||
@@ -237,27 +238,27 @@
|
||||
|
||||
(deftest set-done-test
|
||||
(logged-in! [todo-1 todo-2])
|
||||
(dispatch! [::events/set-done 2 true])
|
||||
(is (= {:uri "/api/todos/2" :method "PATCH" :body {:done true} :on-response [::events/todo-changed]}
|
||||
(dispatch! [::events/set-done (:id todo-2) true])
|
||||
(is (= {:uri (str "/api/todos/" (:id todo-2)) :method "PATCH" :body {:done true} :on-response [::events/todo-changed]}
|
||||
(last-request)))
|
||||
(testing "the todo is replaced by what the server made of it, in place"
|
||||
(answer! 200 (assoc todo-2 :done true :updated-at 5))
|
||||
(is (= [todo-1 (assoc todo-2 :done true :updated-at 5)] (get-in (app-db) [:todos :items]))))
|
||||
(testing "a failure keeps the list and shows why"
|
||||
(dispatch! [::events/set-done 1 true])
|
||||
(dispatch! [::events/set-done (:id todo-1) true])
|
||||
(answer! 404 {:error "todo not found"})
|
||||
(is (= "todo not found" (get-in (app-db) [:todos :error])))
|
||||
(is (false? (:done (first (get-in (app-db) [:todos :items])))))))
|
||||
|
||||
(deftest delete-todo-test
|
||||
(logged-in! [todo-1 todo-2])
|
||||
(dispatch! [::events/delete-todo 1])
|
||||
(is (= {:uri "/api/todos/1" :method "DELETE" :on-response [::events/todo-deleted 1]} (last-request)))
|
||||
(dispatch! [::events/delete-todo (:id todo-1)])
|
||||
(is (= {:uri (str "/api/todos/" (:id todo-1)) :method "DELETE" :on-response [::events/todo-deleted (:id todo-1)]} (last-request)))
|
||||
(testing "it goes from the list"
|
||||
(answer! 200 {:deleted true})
|
||||
(is (= [todo-2] (get-in (app-db) [:todos :items]))))
|
||||
(testing "a failure keeps the list and shows why"
|
||||
(dispatch! [::events/delete-todo 2])
|
||||
(dispatch! [::events/delete-todo (:id todo-2)])
|
||||
(answer! 500 {:error "Boom"})
|
||||
(is (= [todo-2] (get-in (app-db) [:todos :items])))
|
||||
(is (= "Boom" (get-in (app-db) [:todos :error])))))
|
||||
@@ -265,8 +266,8 @@
|
||||
(deftest expired-session-test
|
||||
(testing "a 401 to any change falls back to the logged-out view"
|
||||
(doseq [[event] [[[::events/add-todo]]
|
||||
[[::events/set-done 1 true]]
|
||||
[[::events/delete-todo 1]]]]
|
||||
[[::events/set-done (:id todo-1) true]]
|
||||
[[::events/delete-todo (:id todo-1)]]]]
|
||||
(logged-in! [todo-1])
|
||||
(dispatch! event)
|
||||
(answer! 401 {:error "not logged in"})
|
||||
@@ -308,7 +309,7 @@
|
||||
(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"}
|
||||
:body {:messages [{:role "user" :content "hello there"}] :page "tic-tac-toe"}
|
||||
:on-event [::events/chat-event]
|
||||
:on-response [::events/chat-response]}]
|
||||
(last @effects)))
|
||||
@@ -364,6 +365,24 @@
|
||||
{:role "user" :content "again"}]
|
||||
(get-in (last-request) [:body :messages])))))
|
||||
|
||||
(deftest chat-thinking-time-test
|
||||
;; The view animates "Thinking" while pieces of the reasoning are still coming in: the time of
|
||||
;; the last one is what it goes by.
|
||||
(logged-in! [])
|
||||
(is (nil? (:thinking-at (chat))))
|
||||
(say! "hello")
|
||||
(is (nil? (:thinking-at (chat))) "nothing has been thought yet")
|
||||
(event! {:thinking "Let me "})
|
||||
(let [at (:thinking-at (chat))]
|
||||
(is (number? at))
|
||||
(event! {:content "Hi"})
|
||||
(is (= at (:thinking-at (chat))) "only reasoning moves it"))
|
||||
(event! {:thinking ""})
|
||||
(event! {:done true})
|
||||
(is (nil? (:thinking-at (chat))) "the reply is over")
|
||||
(event! {:thinking "late"})
|
||||
(is (nil? (:thinking-at (chat))) "reasoning nobody waits for is dropped"))
|
||||
|
||||
(deftest chat-thinking-and-text-in-one-event-test
|
||||
(logged-in! [])
|
||||
(say! "hello")
|
||||
@@ -469,6 +488,19 @@
|
||||
(dispatch! [::events/chat-clear])
|
||||
(is (= (assoc (:chat db/default-db) :open? true) (chat)) "starts over, and the panel stays as it was"))
|
||||
|
||||
(deftest chat-toggle-tools-test
|
||||
(logged-in! [])
|
||||
(is (false? (:tools-expanded? (chat))) "tool use is collapsed to a line each by default")
|
||||
(dispatch! [::events/chat-toggle-tools])
|
||||
(is (true? (:tools-expanded? (chat))))
|
||||
(testing "the choice stays when the conversation starts over"
|
||||
(say! "hello")
|
||||
(reply! "Hi!")
|
||||
(dispatch! [::events/chat-clear])
|
||||
(is (true? (:tools-expanded? (chat)))))
|
||||
(dispatch! [::events/chat-toggle-tools])
|
||||
(is (false? (:tools-expanded? (chat)))))
|
||||
|
||||
(deftest chat-resize-test
|
||||
(logged-in! [])
|
||||
(is (= {:width 320 :height 256} (:size (chat))) "the size it starts with")
|
||||
@@ -544,7 +576,7 @@
|
||||
(logged-in! [])
|
||||
(testing "the page the user is on goes along with the conversation"
|
||||
(say! "hi")
|
||||
(is (= "games" (:page (:body (last-request))))))
|
||||
(is (= "tic-tac-toe" (:page (:body (last-request))))))
|
||||
(testing "and follows them, or the LLM itself, to the next page"
|
||||
(reply! "hello")
|
||||
(dispatch! [::events/navigate :ai-setup])
|
||||
@@ -564,13 +596,13 @@
|
||||
(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 "add" :pages #{:tic-tac-toe} :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 (= :tic-tac-toe (:page (app-db))))
|
||||
(is (= ["ping" "add"] (offered))))
|
||||
(testing "the tools follow the page the user is on when the message is sent"
|
||||
(reply! "hello")
|
||||
@@ -585,14 +617,14 @@
|
||||
|
||||
(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}])
|
||||
(model-context/register! [{:name "add" :pages #{:tic-tac-toe} :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])
|
||||
(dispatch! [::events/navigate :tic-tac-toe])
|
||||
(ran! ["c1" "add" "tool not available on this page: add" true])
|
||||
(is (= ["add"] (map :name (:tools (:body (last-request))))))))
|
||||
|
||||
@@ -612,7 +644,7 @@
|
||||
: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]}]
|
||||
(is (= [{:page :tic-tac-toe :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"
|
||||
@@ -745,6 +777,30 @@
|
||||
(select-keys (chat) [:messages :input :status :error :pending-calls])))
|
||||
(is (= [] (tool-calls-asked)))))
|
||||
|
||||
(deftest chat-sends-results-of-earlier-turns-as-out-of-date-test
|
||||
(logged-in! [])
|
||||
(swap! rf-db/app-db assoc-in [:chat :messages]
|
||||
[{:role "user" :content "play"}
|
||||
{:role "assistant" :content "" :tool-calls [(call "old" "get_tic_tac_toe" "{}")]}
|
||||
{:role "tool" :tool-call-id "old" :name "get_tic_tac_toe" :content "{\"turn\":\"x\"}" :error? false}
|
||||
{:role "assistant" :content "Your move."}])
|
||||
(say! "that's wrong, it's my turn")
|
||||
(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 (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))))))
|
||||
(testing "the conversation the user sees keeps the result"
|
||||
(is (= "{\"turn\":\"x\"}" (:content (nth (:messages (chat)) 2)))))
|
||||
(testing "the results of the turn going on are sent as they are, for the chain to continue"
|
||||
(asks! (call "new" "get_tic_tac_toe" "{}"))
|
||||
(ran! ["new" "get_tic_tac_toe" "{\"turn\":\"o\"}" false])
|
||||
(let [sent (get-in (last-request) [:body :messages])]
|
||||
(is (str/includes? (:content (nth sent 2)) "out of date"))
|
||||
(is (= "{\"turn\":\"o\"}" (:content (last sent)))))))
|
||||
|
||||
(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
|
||||
@@ -765,7 +821,7 @@
|
||||
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 "tic-tac-toe-only" :pages #{:tic-tac-toe} :description "d" :input-schema add-schema :handler (fn [_] "played")}
|
||||
{:name "anywhere" :description "d" :input-schema add-schema :handler (fn [_] "ok")}])
|
||||
(async done
|
||||
(tu/finish
|
||||
@@ -773,15 +829,15 @@
|
||||
#(js/Promise. (fn [resolve]
|
||||
(reset! answer resolve)
|
||||
(call-tools-fx {:page :todos
|
||||
:calls [(call "1" "games-only" "{}") (call "2" "anywhere" "{}")]
|
||||
:calls [(call "1" "tic-tac-toe-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]
|
||||
(is (= [["tool not available on this page: tic-tac-toe-only (it is offered on the tic-tac-toe 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]})))
|
||||
(call-tools-fx {:page :tic-tac-toe :calls [(call "1" "tic-tac-toe-only" "{}")] :on-results [::ran]})))
|
||||
(fn [results] (is (= ["played"] (map :content results)))))
|
||||
done))))
|
||||
|
||||
|
||||
@@ -14,50 +14,50 @@
|
||||
|
||||
(deftest definitions-test
|
||||
(testing "nothing registered, nothing offered"
|
||||
(is (= [] (model-context/definitions :games))))
|
||||
(is (= [] (model-context/definitions :tic-tac-toe))))
|
||||
(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))))
|
||||
(model-context/definitions :tic-tac-toe))))
|
||||
(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)))))
|
||||
(is (= [{:name "a" :inputSchema schema}] (model-context/definitions :tic-tac-toe)))))
|
||||
|
||||
(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)))))
|
||||
(is (= [["a" "new"] ["b" "b tool"]] (map (juxt :name :description) (model-context/definitions :tic-tac-toe)))))
|
||||
(testing "tools of other names stay"
|
||||
(model-context/register! [(tool "c" identity)])
|
||||
(is (= ["a" "b" "c"] (map :name (model-context/definitions :games)))))
|
||||
(is (= ["a" "b" "c"] (map :name (model-context/definitions :tic-tac-toe)))))
|
||||
(testing "clear! takes them all"
|
||||
(model-context/clear!)
|
||||
(is (= [] (model-context/definitions :games)))))
|
||||
(is (= [] (model-context/definitions :tic-tac-toe)))))
|
||||
|
||||
(deftest pages-test
|
||||
(model-context/register! [(tool "everywhere" identity)
|
||||
(tool "games-only" identity :pages #{:games})
|
||||
(tool "tic-tac-toe-only" identity :pages #{:tic-tac-toe})
|
||||
(tool "todos-only" identity :pages #{:todos})
|
||||
(tool "both" identity :pages #{:games :todos})])
|
||||
(tool "both" identity :pages #{:tic-tac-toe :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" "tic-tac-toe-only" "both"] (map :name (model-context/definitions :tic-tac-toe))))
|
||||
(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))))))
|
||||
(is (= #{:name :description :inputSchema} (set (mapcat keys (model-context/definitions :tic-tac-toe))))))
|
||||
(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))))))
|
||||
(model-context/register! [(tool "tic-tac-toe-only" identity :pages #{:todos})])
|
||||
(is (= ["everywhere" "both"] (map :name (model-context/definitions :tic-tac-toe))))))
|
||||
|
||||
(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))
|
||||
(model-context/call! :tic-tac-toe tool-name arguments))
|
||||
|
||||
(deftest call-test
|
||||
(let [seen (atom nil)]
|
||||
@@ -104,22 +104,22 @@
|
||||
|
||||
(deftest call-pages-test
|
||||
(let [ran (atom [])]
|
||||
(model-context/register! [(tool "games-only" (fn [_] (swap! ran conj :games-only) "played") :pages #{:games})
|
||||
(model-context/register! [(tool "tic-tac-toe-only" (fn [_] (swap! ran conj :tic-tac-toe-only) "played") :pages #{:tic-tac-toe})
|
||||
(tool "everywhere" (fn [_] (swap! ran conj :everywhere) "ran"))])
|
||||
(async done
|
||||
(tu/finish
|
||||
(tu/steps
|
||||
#(model-context/call! :games "games-only" "{}")
|
||||
#(model-context/call! :tic-tac-toe "tic-tac-toe-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" "{}")
|
||||
#(model-context/call! :todos "tic-tac-toe-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)"
|
||||
(is (= {:text "tool not available on this page: tic-tac-toe-only (it is offered on the tic-tac-toe page)"
|
||||
:error? true}
|
||||
r))))
|
||||
(fn [_] (is (= [:games-only :everywhere] @ran) "and never run")))
|
||||
(fn [_] (is (= [:tic-tac-toe-only :everywhere] @ran) "and never run")))
|
||||
done))))
|
||||
|
||||
(deftest call-failures-test
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[tbd.ui.pages :as pages]))
|
||||
|
||||
(deftest page->hash-test
|
||||
(is (= "#/games" (pages/page->hash :games)))
|
||||
(is (= "#/tic-tac-toe" (pages/page->hash :tic-tac-toe)))
|
||||
(is (= "#/ai-setup" (pages/page->hash :ai-setup))))
|
||||
|
||||
(deftest hash->page-test
|
||||
@@ -17,10 +17,10 @@
|
||||
(testing "what follows the page's name is ignored"
|
||||
(is (= :todos (pages/hash->page "#/todos/1?a=b"))))
|
||||
(testing "no page, or an unknown one, is the first page"
|
||||
(is (= :games pages/default-page))
|
||||
(is (= :tic-tac-toe pages/default-page))
|
||||
(doseq [hash [nil "" "#" "#/" "#/nowhere" "#/Todos"]]
|
||||
(is (= :games (pages/hash->page hash)) (pr-str hash)))))
|
||||
(is (= :tic-tac-toe (pages/hash->page hash)) (pr-str hash)))))
|
||||
|
||||
(deftest pages-test
|
||||
(is (= [:games :todos :ai-setup] (mapv :id pages/pages)) "the order of the tabs")
|
||||
(is (= [:tic-tac-toe :calculator :todos :ai-setup] (mapv :id pages/pages)) "the order of the tabs")
|
||||
(is (= [:todos] (mapv :id (filter :login? pages/pages)))))
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
(deftest plain-subs-test
|
||||
(testing "a new page"
|
||||
(is (= "" (sub [::subs/operand :x])))
|
||||
(is (= :games (sub [::subs/page])))
|
||||
(is (= :tic-tac-toe (sub [::subs/page])))
|
||||
(is (= {:status :idle} (sub [::subs/result])))
|
||||
(is (= {:status :unknown} (sub [::subs/user])))
|
||||
(is (= (:todos db/default-db) (sub [::subs/todos])))
|
||||
@@ -33,13 +33,13 @@
|
||||
(db! assoc-in [:calc :x] "4")
|
||||
(db! assoc-in [:calc :result] {:status :ok :x "4" :y "1" :sum 5})
|
||||
(db! assoc :user {:status :logged-in :login "alice"})
|
||||
(db! assoc-in [:todos :items] [{:id 1 :title "a"}])
|
||||
(db! assoc-in [:todos :items] [{:id "a1" :title "a"}])
|
||||
(db! assoc-in [:mcp :copied?] true)
|
||||
(is (= :todos (sub [::subs/page])))
|
||||
(is (= "4" (sub [::subs/operand :x])))
|
||||
(is (= 5 (:sum (sub [::subs/result]))))
|
||||
(is (= "alice" (:login (sub [::subs/user]))))
|
||||
(is (= [{:id 1 :title "a"}] (:items (sub [::subs/todos]))))
|
||||
(is (= [{:id "a1" :title "a"}] (:items (sub [::subs/todos]))))
|
||||
(is (true? (sub [::subs/mcp-copied?])))))
|
||||
|
||||
(deftest ttt-game-test
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
come first served (a conversation that goes back and forth, as when the LLM uses tools)."
|
||||
(atom nil))
|
||||
|
||||
(defn todo-uuid
|
||||
"The id the fake server gives its `n`th todo: a UUID, as the real server's are, but one a test
|
||||
can write down."
|
||||
[n]
|
||||
(str "00000000-0000-4000-8000-" (.padStart (str n) 12 "0")))
|
||||
|
||||
(defn- respond [status body]
|
||||
{:status status :body (pr-str body)})
|
||||
|
||||
@@ -48,7 +54,7 @@
|
||||
|
||||
(defn- route [method uri body]
|
||||
(let [{:keys [user todos]} @server
|
||||
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
|
||||
todo-id (some->> (re-find #"^/api/todos/([0-9a-f-]{36})$" uri) second)]
|
||||
(cond
|
||||
(= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"}))
|
||||
(and (= "POST" method) (= "/auth/logout" uri)) (do (swap! server assoc :user nil)
|
||||
@@ -73,7 +79,7 @@
|
||||
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
|
||||
(and (= "POST" method) (= "/api/todos" uri))
|
||||
(if (seq (.trim (:title body)))
|
||||
(let [todo {:id (:next-id @server) :owner-id 7 :title (:title body) :done false
|
||||
(let [todo {:id (todo-uuid (:next-id @server)) :owner-id 7 :title (:title body) :done false
|
||||
:created-at 1 :updated-at 1}]
|
||||
(swap! server #(-> % (update :todos conj todo) (update :next-id inc)))
|
||||
(respond 201 todo))
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
#(tu/eventually todos-section)
|
||||
#(tu/eventually (fn [] (or (q "ul li") (= "Nothing to do." (text "section > p")))))))
|
||||
|
||||
(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done true :created-at 2 :updated-at 2})
|
||||
(def ^:private todo-1 {:id (tu/todo-uuid 1) :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id (tu/todo-uuid 2) :owner-id 7 :title "walk dog" :done true :created-at 2 :updated-at 2})
|
||||
|
||||
;; --- The page as a visitor sees it ---
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
(is (= ["API docs" "swagger.json" "Log in"] (nav-links)))
|
||||
(is (= "/auth/login" (.getAttribute (q "a[href=\"/auth/login\"]") "href")))
|
||||
(is (nil? (todos-section)) "no todos without a login")
|
||||
(is (= ["Games" "AI setup"] (tab-titles)) "no Todos tab without a login")
|
||||
(is (= ["Tic-tac-toe" "Calculator"] (mapv #(.-textContent %) (q-all "h2"))) "the first page is Games")))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "AI setup"] (tab-titles)) "no Todos tab without a login")
|
||||
(is (= ["Tic-tac-toe"] (mapv #(.-textContent %) (q-all "h2"))) "the first page is tic-tac-toe")))
|
||||
done)))
|
||||
|
||||
(deftest login-state-error-test
|
||||
@@ -92,7 +92,7 @@
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(open-page! :calculator)
|
||||
#(tu/eventually (fn [] (q "input[name=x]")))
|
||||
(fn []
|
||||
(is (nil? (q "form [role=alert]")))
|
||||
@@ -203,7 +203,7 @@
|
||||
(tu/eventually (fn [] (= ["walk dog"] (todo-titles)))))
|
||||
(fn [_]
|
||||
(is (= ["walk dog"] (todo-titles)))
|
||||
(is (= [2] (mapv :id (:todos @tu/server))))
|
||||
(is (= [(:id todo-2)] (mapv :id (:todos @tu/server))))
|
||||
(click! (tu/by-label "Delete walk dog"))
|
||||
(tu/eventually (fn [] (= "Nothing to do." (text "section > p")))))
|
||||
(fn [emptied]
|
||||
@@ -403,11 +403,18 @@
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (q "nav a")))
|
||||
(fn []
|
||||
(is (= "Games" (current-tab)) "it starts on the first tab")
|
||||
(is (= "#/games" (.getAttribute (q "nav a") "href")))
|
||||
(is (= "Tic-tac-toe" (current-tab)) "it starts on the first tab")
|
||||
(is (= "#/tic-tac-toe" (.getAttribute (q "nav a") "href")))
|
||||
(is (some? (q "[role=status]")) "the game is on the page")
|
||||
(is (nil? (q "input[name=x]")) "and the calculator is not: it has a page of its own")
|
||||
(is (nil? (q "details")) "nothing is folded away")
|
||||
(click! (second (q-all "nav a"))))
|
||||
#(tu/eventually (fn [] (= "Calculator" (current-tab))))
|
||||
(fn []
|
||||
(is (= "#/calculator" js/location.hash))
|
||||
(is (nil? (q "[role=status]")) "the game is gone")
|
||||
(is (some? (q "input[name=x]")) "the calculator is on the page")
|
||||
(click! (last (q-all "nav a"))))
|
||||
#(tu/eventually (fn [] (= "AI setup" (current-tab))))
|
||||
(fn []
|
||||
(is (= "#/ai-setup" js/location.hash) "a page has an address of its own")
|
||||
@@ -415,8 +422,8 @@
|
||||
(is (= ["Add this MCP server to Claude Code" "Browser tools (WebMCP)"]
|
||||
(mapv #(.-textContent %) (q-all "h2"))))
|
||||
;; Back.
|
||||
(set! js/location.hash "#/games"))
|
||||
#(tu/eventually (fn [] (= "Games" (current-tab))))
|
||||
(set! js/location.hash "#/tic-tac-toe"))
|
||||
#(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "following the address (Back, Forward, a typed one) shows that page")
|
||||
(is (some? (q "[role=status]")))))
|
||||
@@ -431,7 +438,7 @@
|
||||
(fn []
|
||||
(is (= "AI setup" (current-tab)) "a page can be opened by its address")
|
||||
(set! js/location.hash "#/nowhere"))
|
||||
#(tu/eventually (fn [] (= "Games" (current-tab))))
|
||||
#(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "an address that names no page shows the first one")))
|
||||
done)))
|
||||
@@ -442,13 +449,13 @@
|
||||
(steps
|
||||
#(log-in-page! [])
|
||||
(fn []
|
||||
(is (= ["Games" "Todos" "AI setup"] (tab-titles)))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "Todos" "AI setup"] (tab-titles)))
|
||||
(is (= "Todos" (current-tab)))
|
||||
(click! (q "nav a"))
|
||||
(tu/eventually (fn [] (= "Games" (current-tab)))))
|
||||
(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab)))))
|
||||
(fn [_]
|
||||
(is (nil? (todos-section)) "the todos are on their own page")
|
||||
(click! (second (q-all "nav a"))))
|
||||
(click! (nth (q-all "nav a") 2)))
|
||||
#(tu/eventually (fn [] (todos-section)))
|
||||
(fn [shown]
|
||||
(is shown "and back")))
|
||||
@@ -462,7 +469,7 @@
|
||||
#(open-page! :todos)
|
||||
#(tu/eventually (fn [] (some #{"Log in"} (nav-links))))
|
||||
(fn []
|
||||
(is (= ["Games" "AI setup"] (tab-titles)))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "AI setup"] (tab-titles)))
|
||||
(is (nil? (todos-section)))
|
||||
(is (= "Todos" (text "section h2")))
|
||||
(is (= "Log in" (text "section p a")))
|
||||
@@ -483,6 +490,16 @@
|
||||
[]
|
||||
(mapv #(.-textContent %) (q-all "[role=log] > div[data-tool]")))
|
||||
|
||||
(defn- tool-toggle-buttons []
|
||||
(filterv #(re-find #"tool use$" (.-textContent %)) (q-all "[role=dialog] button")))
|
||||
|
||||
(defn- tool-toggle [] (first (tool-toggle-buttons)))
|
||||
|
||||
(defn- tool-toggle-labels
|
||||
"The label of the button that expands or collapses the tool use, if the panel has one."
|
||||
[]
|
||||
(mapv #(.-textContent %) (tool-toggle-buttons)))
|
||||
|
||||
(deftest chat-widget-test
|
||||
;; A green square in the bottom left corner, opposite WebMCP's blue one; it opens the panel.
|
||||
(async done
|
||||
@@ -624,7 +641,8 @@
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (:resume @tu/server)))
|
||||
(fn [_]
|
||||
(is (= "Thinking…" (text "[role=status]")) "still thinking while the model reasons")
|
||||
(is (re-matches #"Thinking(\.{1,3}|…)" (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")
|
||||
@@ -643,6 +661,32 @@
|
||||
(is (nil? (q "[role=dialog] [role=alert]")))))
|
||||
done)))
|
||||
|
||||
(deftest chat-thinking-animation-test
|
||||
;; While pieces of the reasoning are coming in "Thinking" has one to three dots; once they stop
|
||||
;; (for a moment) it is "Thinking…" again.
|
||||
(async done
|
||||
(swap! tu/server assoc :chat-script [:pause
|
||||
{:thinking "Let me "} {:thinking "think."} :pause
|
||||
{:content "Hi"} {: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]")) "not animated before any reasoning came")
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (re-matches #"Thinking\.{1,3}" (text "[role=status]"))))
|
||||
(fn [frame]
|
||||
(is (some? frame) "animated while the reasoning is coming in"))
|
||||
#(tu/eventually (fn [] (= "Thinking…" (text "[role=status]"))))
|
||||
(fn [_]
|
||||
(is (= "Thinking…" (text "[role=status]")) "the reasoning stopped coming")
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (nil? (q "[role=status]")))))
|
||||
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
|
||||
@@ -777,15 +821,15 @@
|
||||
(tu/finish
|
||||
(-> (tu/steps
|
||||
#(log-in-page! [])
|
||||
;; The calculator's tools are offered on the games page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :games]))
|
||||
;; The calculator's tools are offered on the calculator page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :calculator]))
|
||||
(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)))
|
||||
(testing "the LLM was told about the tools of the page it was on (the calculator), not all of them"
|
||||
(is (= (set (map :name (filter #(contains? (or (:pages %) #{:calculator}) :calculator) 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}}
|
||||
@@ -793,12 +837,13 @@
|
||||
(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"
|
||||
(testing "each is one line, cut off with an ellipsis where it doesn't fit, until expanded"
|
||||
(is (= ["Expand tool use"] (tool-toggle-labels)))
|
||||
(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 (= "nowrap" (.. line -style -whiteSpace)))
|
||||
(is (= "ellipsis" (.. line -style -textOverflow)))
|
||||
(is (= "hidden" (.. line -style -overflow)))
|
||||
(is (= (.-textContent line) (.getAttribute line "title")) "the whole of it is in the tooltip")))
|
||||
(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"}
|
||||
@@ -807,7 +852,22 @@
|
||||
{: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]")))))))
|
||||
(is (false? (.-readOnly (q "input[name=message]")))))
|
||||
(click! (tool-toggle)))
|
||||
#(tu/eventually (fn [] (= ["Collapse tool use"] (tool-toggle-labels))))
|
||||
(fn [_]
|
||||
(testing "expanded, 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 (= ["Calling add {\"x\":2,\"y\":3}…" "add → {\"x\":2,\"y\":3,\"sum\":5}"]
|
||||
(chat-tool-lines))))
|
||||
(click! (tool-toggle)))
|
||||
#(tu/eventually (fn [] (= ["Expand tool use"] (tool-toggle-labels))))
|
||||
(fn [_]
|
||||
(is (= "nowrap" (.. (q "[data-tool]") -style -whiteSpace)) "collapsed again")))
|
||||
(.finally model-context/clear!))
|
||||
done)))
|
||||
|
||||
@@ -823,8 +883,8 @@
|
||||
(tu/finish
|
||||
(-> (tu/steps
|
||||
#(log-in-page! [])
|
||||
;; The calculator's tools are offered on the games page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :games]))
|
||||
;; The calculator's tools are offered on the calculator page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :calculator]))
|
||||
(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))))
|
||||
|
||||
+238
-38
@@ -1,7 +1,9 @@
|
||||
(ns tbd.ui.webmcp-test
|
||||
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.agent.prompt :as prompt]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.test-util :as tu]
|
||||
[tbd.ui.webmcp :as webmcp]))
|
||||
@@ -12,17 +14,37 @@
|
||||
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private registered-prompts
|
||||
"What the page registered as prompts: name -> {:description :arguments :execute}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private registered-resources
|
||||
"What the page registered as resources: name -> {:description :options :provide}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private fake-mcp
|
||||
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute)."
|
||||
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute),
|
||||
registerPrompt(name, description, arguments, execute) and registerResource(name, description,
|
||||
options, provide)."
|
||||
#js {:registerTool (fn [name description input-schema execute]
|
||||
(swap! registered assoc name {:description description
|
||||
:input-schema input-schema
|
||||
:execute execute}))})
|
||||
:execute execute}))
|
||||
:registerPrompt (fn [name description arguments execute]
|
||||
(swap! registered-prompts assoc name {:description description
|
||||
:arguments arguments
|
||||
:execute execute}))
|
||||
:registerResource (fn [name description options provide]
|
||||
(swap! registered-resources assoc name {:description description
|
||||
:options options
|
||||
:provide provide}))})
|
||||
|
||||
(use-fixtures :each
|
||||
tu/url-fixture
|
||||
{:before (fn []
|
||||
(reset! registered {})
|
||||
(reset! registered-prompts {})
|
||||
(reset! registered-resources {})
|
||||
((:before tu/fake-server-fixture)))
|
||||
:after (:after tu/fake-server-fixture)})
|
||||
|
||||
@@ -59,7 +81,8 @@
|
||||
(is (fn? execute) tool-name))
|
||||
(testing "what the tools need, as the agent sees it"
|
||||
(is (= ["x" "y"] (vec (.. ^js (:input-schema (get @registered "add")) -required))))
|
||||
(is (= ["id" "done"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -required))))))
|
||||
(is (= ["todos"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -required))))
|
||||
(is (= ["id" "done"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -properties -todos -items -required))))))
|
||||
|
||||
(deftest registering-again-replaces-the-tools
|
||||
(webmcp/register-tools! fake-mcp)
|
||||
@@ -142,7 +165,7 @@
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"not logged in" (:text r)))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (empty? (:todos @tu/server)) "nothing was sent")
|
||||
@@ -154,7 +177,7 @@
|
||||
|
||||
(deftest tools-open-the-page-they-act-on
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
(swap! tu/server assoc :todos [{:id (tu/todo-uuid 1) :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
:next-id 2)
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -166,31 +189,104 @@
|
||||
(is (= :ai-setup (page)) "reading changes nothing")
|
||||
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "new_tic_tac_toe_game" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "add" {:x 1 :y 2})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)) "the calculator is on the games page")
|
||||
(is (= :calculator (page)) "the calculator is on its own page")
|
||||
(call "list_todos" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(is (= :calculator (page)))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "set_todo_done" {:id 1 :done true})))
|
||||
(rf/dispatch-sync [::events/navigate :tic-tac-toe])
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "delete_todo" {:id 1})))
|
||||
(rf/dispatch-sync [::events/navigate :tic-tac-toe])
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(done))))))
|
||||
|
||||
(deftest set-todo-done-in-bulk
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos (mapv #(hash-map :id (tu/todo-uuid %) :owner-id 7 :title (str "todo-" %)
|
||||
:done true :created-at 1 :updated-at 1)
|
||||
(range 1 4))
|
||||
:next-id 4)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "whoami" {})
|
||||
(.then (fn [_] (call "set_todo_done" {:todos (mapv #(hash-map :id (tu/todo-uuid %) :done false) (range 1 4))})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= ["todo-1" "todo-2" "todo-3"] (map :title (json (:text r)))))
|
||||
(is (= [false false false] (map :done (json (:text r)))))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= :todos (page)))
|
||||
;; A bad entry, or an unknown todo, refuses the whole batch before anything changes.
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true} {:id (tu/todo-uuid 99) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true} {:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "set_todo_done" {:todos []})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "an empty list is refused")
|
||||
(call "set_todo_done" {:id (tu/todo-uuid 1) :done true})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "the old one-todo arguments are refused")
|
||||
(done))))))
|
||||
|
||||
(deftest create-get-and-delete-todos-in-bulk
|
||||
(tu/log-in!)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "whoami" {})
|
||||
(.then (fn [_] (call "create_todo" {:todos [{:title "milk"} {:title "eggs"} {:title "bread"}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= ["milk" "eggs" "bread"] (map :title (json (:text r)))) "in the order given")
|
||||
(is (= ["milk" "eggs" "bread"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= :todos (page)))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 3)} {:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= ["bread" "milk"] (map :title (json (:text r)))))
|
||||
;; A blank title refuses the batch before the first todo is added.
|
||||
(call "create_todo" {:todos [{:title "jam"} {:title ""}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (= 3 (count (:todos @tu/server))) "nothing was sent")
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 99)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(is (= 3 (count (get-in @rf-db/app-db [:todos :items]))) "an unknown id deletes nothing")
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 2)}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= {:deleted [(tu/todo-uuid 1) (tu/todo-uuid 2)]} (json (:text r))) "a repeated id is deleted once")
|
||||
(is (= ["bread"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= ["bread"] (map :title (:todos @tu/server))))
|
||||
;; The server refuses a blank title, which the page only sees after the first was added.
|
||||
(call "create_todo" {:todos [{:title "jam"} {:title " "} {:title "tea"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"\(the first 1 of 3 were done\)$" (:text r)))
|
||||
(is (= ["bread" "jam"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "delete_todo" {:id (tu/todo-uuid 3)})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "the one-todo arguments are refused")
|
||||
(done))))))
|
||||
|
||||
(deftest navigate
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -200,14 +296,14 @@
|
||||
(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"})))
|
||||
(call "navigate" {:page "tic-tac-toe"})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (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)))
|
||||
(is (re-find #"page must be one of tic-tac-toe, calculator, todos, ai-setup" (:text r)))
|
||||
(call "navigate" {:page "ai-setup"})))
|
||||
(.then (fn [_]
|
||||
(call "navigate" {:page 5})))
|
||||
@@ -227,7 +323,7 @@
|
||||
(.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)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(done))))))
|
||||
|
||||
(deftest navigate-to-the-todos-when-logged-in
|
||||
@@ -245,15 +341,15 @@
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (page-loaded)
|
||||
(.then (fn [_] (call "create_todo" {:title "buy milk"})))
|
||||
(.then (fn [_] (call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "not logged in")
|
||||
(is (= :games (page)) "so there is no todos page to go to")
|
||||
(is (= :tic-tac-toe (page)) "so there is no todos page to go to")
|
||||
(done))))))
|
||||
|
||||
(deftest todos
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
(swap! tu/server assoc :todos [{:id (tu/todo-uuid 1) :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
:next-id 2)
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -263,39 +359,40 @@
|
||||
(call "list_todos" {})))
|
||||
(.then (fn [r]
|
||||
(is (= ["old"] (map :title (json (:text r)))))
|
||||
(call "get_todo" {:id 1})))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= "old" (:title (json (:text r)))))
|
||||
(call "get_todo" {:id 99})))
|
||||
(is (= ["old"] (map :title (json (:text r)))))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 99)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "todo not found" :error? true} r))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= {:id 2 :title "buy milk" :done false} (select-keys (json (:text r)) [:id :title :done])))
|
||||
(is (= [{:id (tu/todo-uuid 2) :title "buy milk" :done false}]
|
||||
(map #(select-keys % [:id :title :done]) (json (:text r)))))
|
||||
;; The page shows it, and the field is empty again.
|
||||
(is (= ["old" "buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= "" (get-in @rf-db/app-db [:todos :new-title])))
|
||||
(call "set_todo_done" {:id 2 :done true})))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (true? (:done (json (:text r)))))
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(is (true? (:done (last (get-in @rf-db/app-db [:todos :items])))))
|
||||
;; Already ticked: nothing to do, and no request.
|
||||
(swap! tu/server assoc :todos [])
|
||||
(call "set_todo_done" {:id 2 :done true})))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (true? (:done (json (:text r)))))
|
||||
(call "set_todo_done" {:id 2 :done "yes"})))
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(call "delete_todo" {:id 1})))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "deleted" :error? false} r))
|
||||
(is (= {:text (str "{\"deleted\":[\"" (tu/todo-uuid 1) "\"]}") :error? false} r))
|
||||
(is (= ["buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "delete_todo" {:id 1})))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "todo not found" :error? true} r))
|
||||
(call "get_todo" {:id 1.5})))
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 1)) :error? true} r))
|
||||
(call "get_todo" {:todos [{:id 42}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(done))))))
|
||||
@@ -304,7 +401,7 @@
|
||||
(tu/log-in!)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "create_todo" {:title " "})
|
||||
(-> (call "create_todo" {:todos [{:title " "}]})
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"Invalid body" (:text r)))
|
||||
@@ -367,3 +464,106 @@
|
||||
(.then (fn [r]
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||
(done))))))
|
||||
|
||||
;; --- Prompts and resources ---
|
||||
|
||||
(defn- prompt-text
|
||||
"What the prompt `prompt-name` gives, on `args` (a map, or nil for none), as a client gets it:
|
||||
{:description :role :type :text} of its one message."
|
||||
[prompt-name args]
|
||||
(let [^js result ((:execute (get @registered-prompts prompt-name)) (when args (clj->js args)))
|
||||
^js message (.. result -messages (at 0))]
|
||||
{:description (.-description result)
|
||||
:role (.-role message)
|
||||
:type (.. message -content -type)
|
||||
:text (.. message -content -text)}))
|
||||
|
||||
(defn- resource-contents
|
||||
"A promise of what the resource `resource-name` gives for `uri`, as {:uri :mime-type :text} of
|
||||
its one piece of content."
|
||||
[resource-name uri]
|
||||
(-> ((:provide (get @registered-resources resource-name)) uri)
|
||||
(.then (fn [^js result]
|
||||
(let [^js content (.. result -contents (at 0))]
|
||||
{:uri (.-uri content) :mime-type (.-mimeType content) :text (.-text content)})))))
|
||||
|
||||
(deftest registers-the-prompts
|
||||
(is (identical? fake-mcp (webmcp/register-prompts! fake-mcp)))
|
||||
(is (= (set (map :name webmcp/prompts)) (set (keys @registered-prompts))))
|
||||
(doseq [[prompt-name {:keys [description arguments execute]}] @registered-prompts]
|
||||
(is (and (string? description) (seq description)) prompt-name)
|
||||
(is (fn? execute) prompt-name)
|
||||
(doseq [^js argument arguments]
|
||||
(is (and (string? (.-name argument)) (string? (.-description argument))) prompt-name)
|
||||
(is (false? (.-required argument)) "an agent can start any of them with no arguments")))
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(is (= (count webmcp/prompts) (count @registered-prompts)) "registering again replaces them"))
|
||||
|
||||
(deftest drive-the-app-prompt-is-the-shared-instructions
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(let [{:keys [role type text description]} (prompt-text "drive_the_app" {:task "add a todo to buy milk"})]
|
||||
(is (= "user" role))
|
||||
(is (= "text" type))
|
||||
(is (seq description))
|
||||
(testing "it is the guide the chatbot's prompt is written alongside, then the request"
|
||||
(is (str/starts-with? text prompt/guide))
|
||||
(is (str/ends-with? text "The user's request: add a todo to buy milk"))
|
||||
(is (re-find #"Act, don't offer" text))))
|
||||
(testing "without a task it has the agent ask what is wanted; a client may send no arguments at all"
|
||||
(doseq [args [nil {} {:task ""}]]
|
||||
(is (re-find #"Ask the user what they want" (:text (prompt-text "drive_the_app" args)))))))
|
||||
|
||||
(deftest every-tool-the-prompts-name-exists
|
||||
(let [tool-names (set (map :name webmcp/tools))]
|
||||
(doseq [text [prompt/guide (prompt/tic-tac-toe "o") (prompt/task nil)]
|
||||
tool-name (re-seq #"\b(?:get|move|new|list|set|create|delete)_[a-z_]+|\badd\{" text)]
|
||||
(is (contains? tool-names (str/replace tool-name "{" "")) (str tool-name)))))
|
||||
|
||||
(deftest tic-tac-toe-prompt
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(testing "the side is the one asked for, and O by default"
|
||||
(is (re-find #"as X\." (:text (prompt-text "play_tic_tac_toe" {:side "x"}))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" {:side "o"}))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" nil))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" {:side "z"})))))
|
||||
(testing "it says to move on the agent's own turn only"
|
||||
(is (re-find #"never move for the other side" (:text (prompt-text "play_tic_tac_toe" nil))))))
|
||||
|
||||
(deftest registers-the-resources
|
||||
(is (identical? fake-mcp (webmcp/register-resources! fake-mcp)))
|
||||
(is (= (set (map :name webmcp/resources)) (set (keys @registered-resources))))
|
||||
(doseq [[resource-name {:keys [description options provide]}] @registered-resources]
|
||||
(is (and (string? description) (seq description)) resource-name)
|
||||
(is (str/starts-with? (.-uri ^js options) "webmcp://app/") resource-name)
|
||||
(is (string? (.-mimeType ^js options)) resource-name)
|
||||
(is (fn? provide) resource-name))
|
||||
(is (= (count webmcp/resources)
|
||||
(count (set (map #(.-uri ^js (:options %)) (vals @registered-resources)))))
|
||||
"each has an address of its own"))
|
||||
|
||||
(deftest the-guide-resource
|
||||
(webmcp/register-resources! fake-mcp)
|
||||
(async done
|
||||
(-> (resource-contents "app_guide" "webmcp://app/guide")
|
||||
(.then (fn [contents]
|
||||
(is (= {:uri "webmcp://app/guide" :mime-type "text/markdown" :text prompt/guide} contents))
|
||||
(done))))))
|
||||
|
||||
(deftest the-state-resource
|
||||
(open-page!)
|
||||
(webmcp/register-resources! fake-mcp)
|
||||
(async done
|
||||
(-> (resource-contents "app_state" "webmcp://app/state")
|
||||
(.then (fn [{:keys [uri mime-type text]}]
|
||||
(is (= "webmcp://app/state" uri))
|
||||
(is (= "application/json" mime-type))
|
||||
(let [state (json text)]
|
||||
(is (= "tic-tac-toe" (:page state)) "the page the app opens on")
|
||||
(is (= ["tic-tac-toe" "calculator" "todos" "ai-setup"] (map :id (:pages state))))
|
||||
(is (= [false false true false] (map #(boolean (:needs-login %)) (:pages state))))
|
||||
(is (= {:logged-in false} (:user state)) "the fake server has no one logged in"))
|
||||
(call "navigate" {:page "calculator"})))
|
||||
(.then (fn [_] (resource-contents "app_state" "webmcp://app/state")))
|
||||
(.then (fn [{:keys [text]}]
|
||||
(is (= "calculator" (:page (json text))) "it follows the page that is open")
|
||||
(done))))))
|
||||
|
||||
Reference in New Issue
Block a user