This commit is contained in:
@@ -12,9 +12,12 @@
|
||||
(rdc/render @root [views/app]))
|
||||
|
||||
(defn- offer-tools!
|
||||
"The page's tools go to the chatbot in the page, and to an agent outside it, through WebMCP."
|
||||
"The page's tools and state go to the chatbot in the page; the tools (all of them, including the
|
||||
read-only ones the chatbot itself isn't offered, see tbd.ui.model-context/definitions) also go to
|
||||
an agent outside it, through WebMCP."
|
||||
[]
|
||||
(model-context/register! webmcp/tools)
|
||||
(model-context/register-state! webmcp/state-providers)
|
||||
(webmcp/register!))
|
||||
|
||||
(defn reload
|
||||
|
||||
+115
-39
@@ -17,16 +17,23 @@
|
||||
(defn- response-map [resp text]
|
||||
{:ok? (.-ok resp) :status (.-status resp) :body text})
|
||||
|
||||
;; Effect: request `uri` (GET unless `method` is given), then dispatch
|
||||
;; Request `uri` (GET unless `method` is given), then dispatch
|
||||
;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN.
|
||||
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
|
||||
(rf/reg-fx
|
||||
::fetch
|
||||
(fn [{:keys [on-response] :as request}]
|
||||
(-> (fetch-request request)
|
||||
(.then (fn [resp]
|
||||
(.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %))))))
|
||||
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
||||
(defn- do-fetch! [{:keys [on-response] :as request}]
|
||||
(-> (fetch-request request)
|
||||
(.then (fn [resp]
|
||||
(.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %))))))
|
||||
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)})))))
|
||||
|
||||
;; Effect: see do-fetch!.
|
||||
(rf/reg-fx ::fetch do-fetch!)
|
||||
|
||||
;; Effect: like ::fetch, for the chat history's own save/clear requests (see save-chat-fx and
|
||||
;; ::chat-clear) — its own effect id so it never lands in the same bucket as the LLM conversation's
|
||||
;; requests (tbd.ui.events-test's `requests`/`last-request` are built on ::fetch/::fetch-stream
|
||||
;; alone, and would otherwise sometimes see this request instead of the one a test is waiting for).
|
||||
(rf/reg-fx ::persist-chat do-fetch!)
|
||||
|
||||
(def ^:private connection-lost {:type "RUN_ERROR" :message "the connection was lost"})
|
||||
|
||||
@@ -172,7 +179,7 @@
|
||||
(cond
|
||||
ok? {:db (assoc db :user {:status :logged-in
|
||||
:login (:login (reader/read-string (:body resp)))})
|
||||
:dispatch [::load-todos]}
|
||||
:dispatch-n [[::load-chat-history] [::load-todos]]}
|
||||
(= 401 status) {:db (assoc db :user {:status :anonymous})}
|
||||
:else {:db (assoc db :user {:status :error :message (error-message resp)})})))
|
||||
|
||||
@@ -331,11 +338,47 @@
|
||||
:function {:name name :arguments arguments}})
|
||||
tool-calls))))
|
||||
|
||||
(defn- windowed
|
||||
"The most recent `n` of `messages`, without the results of calls whose asking is cut off: a
|
||||
result stranded at the front, its call dropped, answers nothing."
|
||||
[n messages]
|
||||
(drop-while #(= "tool" (:role %)) (take-last n messages)))
|
||||
|
||||
(defn- recent
|
||||
"The most recent `max-sent-messages` of `messages`, without the results of calls whose asking
|
||||
is cut off: the LLM server refuses a result that answers nothing."
|
||||
[messages]
|
||||
(drop-while #(= "tool" (:role %)) (take-last max-sent-messages messages)))
|
||||
(windowed max-sent-messages messages))
|
||||
|
||||
(def ^:private max-stored-messages
|
||||
"How much of the conversation is saved for the \"restore my last session\" feature
|
||||
(tbd.chat.schema/max-history-messages, which this stays within): more than max-sent-messages,
|
||||
since this is scrollback for the user to read, not context sent to the LLM."
|
||||
100)
|
||||
|
||||
(defn- storable
|
||||
"The most recent `max-stored-messages` of `messages`, as /api/chat/history's PUT wants them: the
|
||||
browser's own internal shape (see tbd.ui.db), not AG-UI's wire Message — that endpoint is this
|
||||
app's own addition, not part of an AG-UI run, so nothing is lost translating to and from it."
|
||||
[messages]
|
||||
(windowed max-stored-messages messages))
|
||||
|
||||
;; Effect: best-effort save of the conversation so far, for /api/chat/history to hand back on the
|
||||
;; next login. Never blocks anything: on failure the next natural save point (the next message
|
||||
;; sent, or the turn after) tries again with the conversation as it is by then.
|
||||
(defn- save-chat-fx [db]
|
||||
{:uri "/api/chat/history" :method "PUT"
|
||||
:body {:messages (storable (get-in db [:chat :messages]))}
|
||||
:on-response [::save-chat-history-response]})
|
||||
|
||||
(rf/reg-event-db
|
||||
::save-chat-history-response
|
||||
(fn [db [_ {:keys [status]}]]
|
||||
;; A 401 means the session expired since the save was sent; anything else is a transient
|
||||
;; failure the next save point papers over, so there is nothing to do for it.
|
||||
(if (= 401 status)
|
||||
(-> db (assoc :user {:status :anonymous}) reset-chat)
|
||||
db)))
|
||||
|
||||
(def ^:private stale-result
|
||||
"What the LLM is sent in place of a result from before the user's last message."
|
||||
@@ -363,19 +406,38 @@
|
||||
description (assoc :description description)))
|
||||
|
||||
;; The request that sends the conversation: an AG-UI RunAgentInput. It goes with the page being
|
||||
;; shown (as a `context` entry, for the LLM to know where the user is) and the tools that page
|
||||
;; offers the LLM. Both are read from the db every time, so a conversation that moves between
|
||||
;; pages (the LLM's own `navigate`, say) always tells the LLM where it is now.
|
||||
;; shown (as a `context` entry, for the LLM to know where the user is), the tools that page offers
|
||||
;; the LLM, and its current state (`state`, see tbd.ui.model-context/current-state and
|
||||
;; tbd.chat.schema/RunAgentInput) — what a read-only tool would have told it, so it never has to
|
||||
;; spend a call finding out. All three are read from the db every time, so a conversation that
|
||||
;; moves between pages (the LLM's own `navigate`, say) always tells the LLM where it is now.
|
||||
(defn- chat-request [db messages]
|
||||
(let [tools (model-context/definitions (:page db))]
|
||||
(let [tools (model-context/definitions (:page db))
|
||||
state (model-context/current-state (:page db))]
|
||||
{:uri "/api/chat" :method "POST"
|
||||
:body (cond-> {:threadId (next-id) :runId (next-id)
|
||||
:messages (mapv wire-message (without-stale-results (recent messages)))
|
||||
:context [{:description "page" :value (name (:page db))}]}
|
||||
(seq tools) (assoc :tools (mapv wire-tool tools)))
|
||||
(seq tools) (assoc :tools (mapv wire-tool tools))
|
||||
(seq state) (assoc :state state))
|
||||
:on-event [::chat-event]
|
||||
:on-response [::chat-response]}))
|
||||
|
||||
;; Restoring the last session: fetched once, right after login (see ::user-response), and merged
|
||||
;; in only if nothing has been said in this page load yet — a slow response landing after the user
|
||||
;; already started a fresh conversation would otherwise stomp on it.
|
||||
(rf/reg-event-fx
|
||||
::load-chat-history
|
||||
(fn [_ _]
|
||||
{::fetch {:uri "/api/chat/history" :on-response [::chat-history-response]}}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::chat-history-response
|
||||
(fn [db [_ {:keys [ok? body]}]]
|
||||
(if (and ok? (empty? (get-in db [:chat :messages])))
|
||||
(assoc-in db [:chat :messages] (:messages (reader/read-string body)))
|
||||
db)))
|
||||
|
||||
(rf/reg-event-db
|
||||
::chat-toggle
|
||||
(fn [db _]
|
||||
@@ -406,10 +468,12 @@
|
||||
(let [{:keys [input messages status]} (:chat db)
|
||||
content (str/trim input)]
|
||||
(when (and (not= :loading status) (seq content))
|
||||
(let [messages (conj messages {:role "user" :content content})]
|
||||
{:db (update db :chat assoc :messages messages :input "" :status :loading :error nil
|
||||
:tool-calls-used 0)
|
||||
::fetch-stream (chat-request db messages)})))))
|
||||
(let [messages (conj messages {:role "user" :content content})
|
||||
db (update db :chat assoc :messages messages :input "" :status :loading :error nil
|
||||
:tool-calls-used 0)]
|
||||
{:db db
|
||||
::fetch-stream (chat-request db messages)
|
||||
::persist-chat (save-chat-fx db)})))))
|
||||
|
||||
(defn- only-reasoning?
|
||||
"Whether `message` is a reply that has come to nothing but the reasoning for it: there is nothing
|
||||
@@ -436,13 +500,14 @@
|
||||
;; The server answered without a stream: the request failed. A 401 means the session expired: fall
|
||||
;; back to the logged-out view. A response nobody is waiting for (the conversation was reset since,
|
||||
;; by logging out) is dropped.
|
||||
(rf/reg-event-db
|
||||
(rf/reg-event-fx
|
||||
::chat-response
|
||||
(fn [db [_ {:keys [status] :as resp}]]
|
||||
(fn [{:keys [db]} [_ {:keys [status] :as resp}]]
|
||||
(cond
|
||||
(not= :loading (get-in db [:chat :status])) db
|
||||
(= 401 status) (-> db (assoc :user {:status :anonymous}) reset-chat)
|
||||
:else (chat-failed db (error-message resp)))))
|
||||
(not= :loading (get-in db [:chat :status])) {:db db}
|
||||
(= 401 status) {:db (-> db (assoc :user {:status :anonymous}) reset-chat)}
|
||||
:else (let [db (chat-failed db (error-message resp))]
|
||||
{:db db ::persist-chat (save-chat-fx db)}))))
|
||||
|
||||
(defn- append-to-reply
|
||||
"The conversation's assistant message being written, with `text` added to its content. The first
|
||||
@@ -478,18 +543,20 @@
|
||||
db (update db :chat assoc :pending-calls nil :thinking-at nil)]
|
||||
(cond
|
||||
(empty? pending-calls)
|
||||
{:db (assoc-in db [:chat :status] :idle)}
|
||||
(let [db (assoc-in db [:chat :status] :idle)]
|
||||
{:db db ::persist-chat (save-chat-fx db)})
|
||||
|
||||
(> used max-tool-calls-per-turn)
|
||||
;; The calls still get their answers, for the conversation to make sense to the LLM later.
|
||||
{:db (-> db
|
||||
(update-in [:chat :messages] with-tool-calls pending-calls)
|
||||
(update-in [:chat :messages] into
|
||||
(for [{:keys [id name]} pending-calls]
|
||||
(tool-message {:id id :name name :error? true
|
||||
:content "not run: too many tool calls in one turn"})))
|
||||
(update :chat assoc :status :idle
|
||||
:error "The assistant made too many tool calls in a row, so it was stopped."))}
|
||||
(let [db (-> db
|
||||
(update-in [:chat :messages] with-tool-calls pending-calls)
|
||||
(update-in [:chat :messages] into
|
||||
(for [{:keys [id name]} pending-calls]
|
||||
(tool-message {:id id :name name :error? true
|
||||
:content "not run: too many tool calls in one turn"})))
|
||||
(update :chat assoc :status :idle
|
||||
:error "The assistant made too many tool calls in a row, so it was stopped."))]
|
||||
{:db db ::persist-chat (save-chat-fx db)})
|
||||
|
||||
:else
|
||||
{:db (-> db
|
||||
@@ -535,7 +602,8 @@
|
||||
(= "TOOL_CALL_ARGS" type) {:db (append-tool-call-args db event)}
|
||||
(= "TOOL_CALL_END" type) {:db (end-tool-call db event)}
|
||||
(= "RUN_FINISHED" type) (finish-reply db)
|
||||
(= "RUN_ERROR" type) {:db (chat-failed db (:message event))}
|
||||
(= "RUN_ERROR" type) (let [db (chat-failed db (:message event))]
|
||||
{:db db ::persist-chat (save-chat-fx db)})
|
||||
:else {:db db}))))
|
||||
|
||||
(defn- call-all
|
||||
@@ -580,13 +648,21 @@
|
||||
{:db db
|
||||
::fetch-stream (chat-request db (get-in db [:chat :messages]))}))))
|
||||
|
||||
;; Starts the conversation over. Not while a reply is on its way: it would land in the new one.
|
||||
(rf/reg-event-db
|
||||
;; Starts a new session: not while a reply is on its way (it would land in the new one), and the
|
||||
;; stored history goes with it, so a reload doesn't bring the old conversation back.
|
||||
(rf/reg-event-fx
|
||||
::chat-clear
|
||||
(fn [db _]
|
||||
(fn [{:keys [db]} _]
|
||||
(if (= :loading (get-in db [:chat :status]))
|
||||
db
|
||||
(reset-chat db))))
|
||||
{:db db}
|
||||
{:db (reset-chat db)
|
||||
::persist-chat {:uri "/api/chat/history" :method "DELETE" :on-response [::clear-chat-history-response]}})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::clear-chat-history-response
|
||||
;; Best-effort: a failure here just means the old session may come back on the next reload,
|
||||
;; which the next successful save (or clear) fixes.
|
||||
(fn [db _] db))
|
||||
|
||||
;; --- MCP setup page ---
|
||||
|
||||
|
||||
@@ -17,10 +17,18 @@
|
||||
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."
|
||||
can neither be listed nor called.
|
||||
|
||||
Besides tools, the page registers state providers: {:pages :provide}, `provide` a plain function
|
||||
of no arguments returning the page's current state as a map (`pages` means the same as a tool's).
|
||||
tbd.ui.events attaches `(current-state page)` to every /api/chat request (see
|
||||
tbd.chat.schema/RunAgentInput's `state`), so the model has what the page shows already, without
|
||||
spending a tool call to read it — tbd.ui.webmcp's `state-providers` is where these live, since
|
||||
they need `db`, and it already requires this ns for the tools."
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defonce ^:private registry (atom []))
|
||||
(defonce ^:private state-registry (atom []))
|
||||
|
||||
(defn register!
|
||||
"Adds `tools` to what the page offers. A tool with the name of one already there replaces it
|
||||
@@ -31,27 +39,49 @@
|
||||
nil))
|
||||
|
||||
(defn clear!
|
||||
"Takes every tool away: for a test to start from a page that offers none."
|
||||
"Takes every tool and state provider away: for a test to start from a page that offers none."
|
||||
[]
|
||||
(reset! registry [])
|
||||
(reset! state-registry [])
|
||||
nil)
|
||||
|
||||
(defn- offered?
|
||||
"Whether `tool` is offered on `page`."
|
||||
"Whether `entry` (a tool or a state provider) is offered on `page`."
|
||||
[page {:keys [pages]}]
|
||||
(or (nil? pages) (contains? pages page)))
|
||||
|
||||
(defn definitions
|
||||
"What the LLM is told about the tools offered on `page`, as MCP's `tools/list` gives them:
|
||||
[{:name :description :inputSchema}], in the order they were registered."
|
||||
[{:name :description :inputSchema}], in the order they were registered. A `:read-only?` tool
|
||||
(one that only reads what `current-state` already covers, like list_todos) is left out: the
|
||||
chatbot is handed the state instead of a call to read it, but the tool stays registered for
|
||||
WebMCP, whose outside agents have no state channel to fall back on."
|
||||
[page]
|
||||
(into []
|
||||
(comp (filter #(offered? page %))
|
||||
(remove :read-only?)
|
||||
(map (fn [{:keys [name description input-schema]}]
|
||||
(cond-> {:name name :inputSchema input-schema}
|
||||
description (assoc :description description)))))
|
||||
@registry))
|
||||
|
||||
(defn register-state!
|
||||
"Replaces what the page offers as state with `providers` ({:pages :provide}, see this ns's
|
||||
docstring). Unlike `register!`'s tools (registered piecemeal, by name, from more than one
|
||||
caller), there is one caller (tbd.ui.core) and one list, so registering again — hot reload —
|
||||
just replaces it, rather than needing a name to replace by."
|
||||
[providers]
|
||||
(reset! state-registry (vec providers))
|
||||
nil)
|
||||
|
||||
(defn current-state
|
||||
"What `page` shows right now, as one map: every state provider offered on it, merged (later ones
|
||||
win a key both share, though none do today). {} when nothing is registered for it, or nothing
|
||||
goes to the LLM (tbd.ui.events omits an empty state rather than sending it)."
|
||||
[page]
|
||||
(transduce (comp (filter #(offered? page %)) (map (fn [{:keys [provide]}] (provide))))
|
||||
merge {} @state-registry))
|
||||
|
||||
(defn- ->text [value]
|
||||
(cond
|
||||
(string? value) value
|
||||
|
||||
+52
-26
@@ -16,7 +16,10 @@
|
||||
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
|
||||
agent outside the page.
|
||||
agent outside the page. A `:read-only? true` tool (one that only reads what `state-providers`
|
||||
already covers, like list_todos) stays registered here for WebMCP's outside agents, but is left
|
||||
out of what the page's own chatbot is offered (see tbd.ui.model-context/definitions): that
|
||||
chatbot is handed the state instead, through `state-providers`.
|
||||
|
||||
A tool takes its arguments as a map and returns a value or a promise of one: a string, or data
|
||||
that becomes JSON. Throwing (or rejecting) becomes an error result the agent can read. Input
|
||||
@@ -215,13 +218,16 @@
|
||||
(.then #(last (get-in % [:todos :items])))))
|
||||
|
||||
(defn- create-todo
|
||||
"Adds todos, one after the other: a promise of the todos that were created."
|
||||
"Adds todos, one after the other, and resolves to nil (\"ok\"): the page shows them as they're
|
||||
added, and the next request's state (tbd.ui.webmcp/state-providers) has them, ids included, so
|
||||
there is nothing left for the result itself to say."
|
||||
[{: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)))))
|
||||
(.then (fn [_] (in-order create-one todos)))
|
||||
(.then (constantly nil))))
|
||||
|
||||
(defn- set-one-done
|
||||
"Ticks or unticks one todo's checkbox, unless it is already so (no request then). A promise of
|
||||
@@ -237,7 +243,7 @@
|
||||
|
||||
(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."
|
||||
made. Resolves to nil (\"ok\"): the checkboxes show it, and the next request's state has it too."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos (fn [{:keys [id done]}]
|
||||
(check-id! id)
|
||||
@@ -247,7 +253,8 @@
|
||||
(.then (fn [items]
|
||||
(run! #(find-todo! items (:id %)) todos)
|
||||
(show! :todos)
|
||||
(in-order set-one-done todos)))))
|
||||
(in-order set-one-done todos)))
|
||||
(.then (constantly nil))))
|
||||
|
||||
(defn- delete-one
|
||||
"Presses a todo's Delete button. A promise of its id."
|
||||
@@ -258,7 +265,8 @@
|
||||
(.then (fn [_] id))))
|
||||
|
||||
(defn- delete-todo
|
||||
"Presses the Delete button of several todos. Every id is looked up before the first is deleted."
|
||||
"Presses the Delete button of several todos. Every id is looked up before the first is deleted.
|
||||
Resolves to nil (\"ok\"): they're gone from the page, and from the next request's state."
|
||||
[{:keys [todos]}]
|
||||
(check-todos! todos #(check-id! (:id %)))
|
||||
(let [ids (distinct (map :id todos))]
|
||||
@@ -267,7 +275,7 @@
|
||||
(run! #(find-todo! items %) ids)
|
||||
(show! :todos)
|
||||
(in-order delete-one ids)))
|
||||
(.then (fn [deleted] {:deleted deleted})))))
|
||||
(.then (constantly nil)))))
|
||||
|
||||
;; 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
|
||||
@@ -289,8 +297,28 @@
|
||||
(defn- get-tic-tac-toe [_]
|
||||
(tic-tac-toe-state))
|
||||
|
||||
;; --- State (tbd.ui.model-context/current-state, attached to every request of the page's own
|
||||
;; chatbot instead of spent as a read tool's call; see tbd.ui.model-context's docstring) ---
|
||||
|
||||
(defn- todos-state
|
||||
"The todos, once they have loaded: nil (nothing to add to the state) while they haven't, or
|
||||
there is no login, rather than an empty list that would read as \"there are none\"."
|
||||
[]
|
||||
(let [{:keys [status items]} (:todos (db))]
|
||||
(when (= :ok status)
|
||||
{:todos (mapv #(select-keys % [:id :title :done]) items)})))
|
||||
|
||||
(def state-providers
|
||||
"{:pages :provide}, one per page that has state worth telling the model up front. `provide`
|
||||
returns the page's state as a map, or nil for nothing to add right now (see
|
||||
tbd.ui.model-context/current-state, which merges them)."
|
||||
[{:pages #{:todos} :provide todos-state}
|
||||
{:pages #{:tic-tac-toe} :provide tic-tac-toe-state}
|
||||
{:pages #{:calculator} :provide (fn [] (:calc (db)))}])
|
||||
|
||||
(defn- move-tic-tac-toe
|
||||
"Presses cell `cell` as `player` (\"x\" or \"o\"), only if it is that player's turn."
|
||||
"Presses cell `cell` as `player` (\"x\" or \"o\"), only if it is that player's turn. Resolves to
|
||||
nil (\"ok\") once it lands: the board shows it, and the next request's state has it too."
|
||||
[{:keys [cell player]}]
|
||||
(when-not (and (number? cell) (js/Number.isInteger cell) (<= 0 cell 8))
|
||||
(fail! "invalid arguments: cell must be an integer from 0 to 8"))
|
||||
@@ -305,14 +333,14 @@
|
||||
(fail! (str "cell " cell " is taken")))
|
||||
(show! :tic-tac-toe)
|
||||
(rf/dispatch-sync [::events/ttt-move cell])
|
||||
(tic-tac-toe-state)))
|
||||
nil))
|
||||
|
||||
(defn- new-tic-tac-toe-game
|
||||
"Presses New game."
|
||||
"Presses New game. Resolves to nil (\"ok\"): the empty board shows it, and so does the state."
|
||||
[_]
|
||||
(show! :tic-tac-toe)
|
||||
(rf/dispatch-sync [::events/ttt-reset])
|
||||
(tic-tac-toe-state))
|
||||
nil)
|
||||
|
||||
(defn- object-schema [properties required]
|
||||
{:type "object" :properties properties :required required})
|
||||
@@ -352,7 +380,7 @@
|
||||
["page"])
|
||||
:handler navigate}
|
||||
|
||||
{:name "get_calculator" :pages #{:calculator}
|
||||
{:name "get_calculator" :pages #{:calculator} :read-only? true
|
||||
: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}
|
||||
@@ -365,29 +393,28 @@
|
||||
["x" "y"])
|
||||
:handler add}
|
||||
|
||||
{:name "list_todos" :pages #{:todos}
|
||||
{:name "list_todos" :pages #{:todos} :read-only? true
|
||||
:description (str "Lists the todos this page shows (JSON: id, title, done, created-at, updated-at), oldest first. "
|
||||
"Needs a login on the page")
|
||||
:input-schema (object-schema {} []) :handler list-todos}
|
||||
|
||||
{:name "get_todo" :pages #{:todos}
|
||||
{:name "get_todo" :pages #{:todos} :read-only? true
|
||||
: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 (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")
|
||||
:description (str "Adds todos: types each title into the new-todo field and presses Add. 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 (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")
|
||||
:description (str "Ticks or unticks the checkboxes of todos. 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"}}
|
||||
@@ -395,14 +422,14 @@
|
||||
:handler set-todo-done}
|
||||
|
||||
{:name "delete_todo" :pages #{:todos}
|
||||
: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 "
|
||||
:description (str "Deletes todos for good, by pressing their Delete buttons. Find the id first — from the "
|
||||
"page's current state if you have it, or list_todos — 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 #{:tic-tac-toe}
|
||||
{:name "get_tic_tac_toe" :pages #{:tic-tac-toe} :read-only? true
|
||||
: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; "
|
||||
@@ -411,9 +438,8 @@
|
||||
:input-schema (object-schema {} []) :handler get-tic-tac-toe}
|
||||
|
||||
{: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 "
|
||||
:description (str "Presses a cell of the tic-tac-toe board as `player`, your side. 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 "
|
||||
"cell is taken or the game is over. Never move for the other side")
|
||||
:input-schema (object-schema {:cell {:type "integer" :minimum 0 :maximum 8
|
||||
:description "0 is the top left, 2 the top right, 8 the bottom right"}
|
||||
@@ -424,7 +450,7 @@
|
||||
:handler move-tic-tac-toe}
|
||||
|
||||
{: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)"
|
||||
:description "Presses New game: clears the tic-tac-toe board, X to move"
|
||||
:input-schema (object-schema {} []) :handler new-tic-tac-toe-game}])
|
||||
|
||||
;; --- Prompts and resources ---
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
(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")
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn :x}
|
||||
(model-context/current-state :tic-tac-toe))
|
||||
"and its state, for the page it starts on")
|
||||
;; A tool, as the agent's client calls it.
|
||||
(let [ping (.-execute (.get (.-availableTools ^js (widget)) "ping"))]
|
||||
(-> (ping #js {})
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
:after model-context/clear!}
|
||||
(tu/stub-effects-fixture {::events/fetch (record ::events/fetch)
|
||||
::events/fetch-stream (record ::events/fetch-stream)
|
||||
::events/persist-chat (record ::events/persist-chat)
|
||||
::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 %)}))
|
||||
:dispatch #(swap! queued conj %)
|
||||
:dispatch-n #(swap! queued into %)}))
|
||||
|
||||
(defn- dispatch!
|
||||
"Dispatches `event` and, one after the other, the events it sets off."
|
||||
@@ -49,6 +51,14 @@
|
||||
|
||||
(defn- last-request [] (last (requests)))
|
||||
|
||||
(defn- persist-requests
|
||||
"The chat history save/clear requests asked for so far (see ::events/persist-chat), as
|
||||
{:uri :method :body :on-response}."
|
||||
[]
|
||||
(mapv second (filter (comp #{::events/persist-chat} first) @effects)))
|
||||
|
||||
(defn- last-persist-request [] (last (persist-requests)))
|
||||
|
||||
(defn- answer!
|
||||
"The server answers the last request."
|
||||
([status body] (answer! (last-request) status body))
|
||||
@@ -162,10 +172,11 @@
|
||||
;; --- Login ---
|
||||
|
||||
(deftest user-response-test
|
||||
(testing "logged in: remembers who, and loads the todos"
|
||||
(testing "logged in: remembers who, loads the todos, and asks for the last chat session"
|
||||
(start!)
|
||||
(answer! 200 {:login "alice" :id 7})
|
||||
(is (= {:status :logged-in :login "alice"} (:user (app-db))))
|
||||
(is (= #{"/auth/me" "/api/todos" "/api/chat/history"} (set (map :uri (requests)))))
|
||||
(is (= "/api/todos" (:uri (last-request)))))
|
||||
(testing "401 only means nobody is logged in"
|
||||
(start!)
|
||||
@@ -338,8 +349,7 @@
|
||||
(logged-in! [])
|
||||
(say! " hello there ")
|
||||
(testing "the trimmed message joins the conversation and goes to the server, for a stream back"
|
||||
(let [[effect request] (last @effects)]
|
||||
(is (= ::events/fetch-stream effect))
|
||||
(let [request (last-request)]
|
||||
(is (= "/api/chat" (:uri request)))
|
||||
(is (= "POST" (:method request)))
|
||||
(is (= [::events/chat-event] (:on-event request)))
|
||||
@@ -347,7 +357,11 @@
|
||||
(is (= [{:role "user" :content "hello there"}] (sent-messages request)))
|
||||
(is (= [{:description "page" :value "tic-tac-toe"}] (get-in request [:body :context]))))
|
||||
(is (= {:messages [{:role "user" :content "hello there"}] :input "" :status :loading :error nil}
|
||||
(select-keys (chat) [:messages :input :status :error]))))
|
||||
(select-keys (chat) [:messages :input :status :error])))
|
||||
(testing "and is saved for the next login, right away (not only once the reply comes)"
|
||||
(is (= {:uri "/api/chat/history" :method "PUT"
|
||||
:body {:messages [{:role "user" :content "hello there"}]}}
|
||||
(select-keys (last-persist-request) [:uri :method :body])))))
|
||||
(testing "only one message is in flight, so sending again does nothing"
|
||||
(say! "and another")
|
||||
(is (= 1 (count (requests))))
|
||||
@@ -532,7 +546,41 @@
|
||||
(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"))
|
||||
(is (= (assoc (:chat db/default-db) :open? true) (chat)) "starts over, and the panel stays as it was")
|
||||
(testing "the stored session goes with it, so a reload doesn't bring the old conversation back"
|
||||
(is (= {:uri "/api/chat/history" :method "DELETE"} (select-keys (last-persist-request) [:uri :method])))))
|
||||
|
||||
(deftest chat-history-restore-test
|
||||
(testing "the last session comes back, once, right after login"
|
||||
(start!)
|
||||
(answer! 200 {:login "alice" :id 7})
|
||||
(let [history-request (first (filter #(= "/api/chat/history" (:uri %)) (requests)))
|
||||
messages [{:role "user" :content "hi"} {:role "assistant" :content "echo: hi"}]]
|
||||
(answer! history-request 200 {:messages messages})
|
||||
(answer! 200 {:todos []})
|
||||
(is (= messages (:messages (chat))))))
|
||||
(testing "an empty history leaves the conversation empty"
|
||||
(start!)
|
||||
(answer! 200 {:login "bob" :id 8})
|
||||
(let [history-request (first (filter #(= "/api/chat/history" (:uri %)) (requests)))]
|
||||
(answer! history-request 200 {:messages []})
|
||||
(answer! 200 {:todos []})
|
||||
(is (= [] (:messages (chat))))))
|
||||
(testing "a conversation already started (the response arriving late) is not overwritten"
|
||||
(start!)
|
||||
(answer! 200 {:login "alice" :id 7})
|
||||
(let [history-request (first (filter #(= "/api/chat/history" (:uri %)) (requests)))]
|
||||
(answer! 200 {:todos []})
|
||||
(say! "already started")
|
||||
(answer! history-request 200 {:messages [{:role "user" :content "old session"}]})
|
||||
(is (= [{:role "user" :content "already started"}] (:messages (chat))))))
|
||||
(testing "a failed load (a 401, say) just leaves the conversation empty"
|
||||
(start!)
|
||||
(answer! 200 {:login "alice" :id 7})
|
||||
(let [history-request (first (filter #(= "/api/chat/history" (:uri %)) (requests)))]
|
||||
(answer! history-request 401 {:error "login required"})
|
||||
(answer! 200 {:todos []})
|
||||
(is (= [] (:messages (chat)))))))
|
||||
|
||||
(deftest chat-toggle-tools-test
|
||||
(logged-in! [])
|
||||
@@ -618,6 +666,27 @@
|
||||
(is (= [{:name "add" :description "Adds" :parameters add-schema}]
|
||||
(:tools (:body (last-request)))))))
|
||||
|
||||
(deftest chat-offers-the-state-test
|
||||
(logged-in! [])
|
||||
(testing "no state provider on the page, none offered"
|
||||
(say! "hi")
|
||||
(is (not (contains? (:body (last-request)) :state))))
|
||||
(testing "the page's state goes along with the conversation, as tbd.chat.schema/RunAgentInput's state"
|
||||
(reply! "hello")
|
||||
(model-context/register-state! [{:provide (fn [] {:board (vec (repeat 9 nil))})}])
|
||||
(say! "again")
|
||||
(is (= {:board (vec (repeat 9 nil))} (:state (:body (last-request))))))
|
||||
(testing "a provider offered on another page adds nothing here"
|
||||
(reply! "hello")
|
||||
(model-context/register-state! [{:provide (fn [] {:todos []}) :pages #{:todos}}])
|
||||
(say! "once more")
|
||||
(is (not (contains? (:body (last-request)) :state))))
|
||||
(testing "a provider that has nothing to say (nil) adds nothing"
|
||||
(reply! "hello")
|
||||
(model-context/register-state! [{:provide (fn [] nil)}])
|
||||
(say! "again")
|
||||
(is (not (contains? (:body (last-request)) :state)))))
|
||||
|
||||
(defn- page-of [request]
|
||||
(some #(when (= "page" (:description %)) (:value %)) (get-in request [:body :context])))
|
||||
|
||||
|
||||
@@ -122,6 +122,20 @@
|
||||
(fn [_] (is (= [:tic-tac-toe-only :everywhere] @ran) "and never run")))
|
||||
done))))
|
||||
|
||||
(deftest current-state-test
|
||||
(testing "nothing registered, nothing to offer"
|
||||
(is (= {} (model-context/current-state :todos))))
|
||||
(model-context/register-state! [{:provide (fn [] {:todos [{:title "milk"}]}) :pages #{:todos}}
|
||||
{:provide (fn [] {:board (vec (repeat 9 nil))}) :pages #{:tic-tac-toe}}
|
||||
{:provide (fn [] {:page-status "ok"})}])
|
||||
(testing "only the providers offered on the page run, merged into one map"
|
||||
(is (= {:todos [{:title "milk"}] :page-status "ok"} (model-context/current-state :todos)))
|
||||
(is (= {:board (vec (repeat 9 nil)) :page-status "ok"} (model-context/current-state :tic-tac-toe)))
|
||||
(is (= {:page-status "ok"} (model-context/current-state :calculator))))
|
||||
(testing "clear! takes state providers away too"
|
||||
(model-context/clear!)
|
||||
(is (= {} (model-context/current-state :todos)))))
|
||||
|
||||
(deftest call-failures-test
|
||||
(let [ran? (atom false)]
|
||||
(model-context/register! [(tool "t" (fn [_] (reset! ran? true) "ran"))])
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
{:content "echo: "}
|
||||
{:content (:content (last (:messages body)))}
|
||||
{:done true}]))))
|
||||
(and (= "GET" method) (= "/api/chat/history" uri)) (respond 200 {:messages (:chat-history @server [])})
|
||||
(and (= "PUT" method) (= "/api/chat/history" uri))
|
||||
(do (swap! server assoc :chat-history (:messages body))
|
||||
(respond 200 {:saved true}))
|
||||
(and (= "DELETE" method) (= "/api/chat/history" uri))
|
||||
(do (swap! server assoc :chat-history [])
|
||||
(respond 200 {:cleared true}))
|
||||
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
|
||||
(and (= "POST" method) (= "/api/todos" uri))
|
||||
(if (seq (.trim (:title body)))
|
||||
|
||||
@@ -828,8 +828,10 @@
|
||||
#(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 calculator), not all of them"
|
||||
(is (= (set (map :name (filter #(contains? (or (:pages %) #{:calculator}) :calculator) webmcp/tools)))
|
||||
(testing "the LLM was told about the tools of the page it was on (the calculator), not all of them,
|
||||
and not the read-only ones (get_calculator): its state covers those"
|
||||
(is (= (set (map :name (remove :read-only? (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}}
|
||||
|
||||
@@ -225,9 +225,7 @@
|
||||
(-> (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 (= {:text "ok" :error? false} r) "nothing left to say: the page shows it, and so will the next state")
|
||||
(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.
|
||||
@@ -254,9 +252,8 @@
|
||||
(-> (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 (= {:text "ok" :error? false} r))
|
||||
(is (= ["milk" "eggs" "bread"] (map :title (get-in @rf-db/app-db [:todos :items]))) "in the order given")
|
||||
(is (= :todos (page)))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 3)} {:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
@@ -272,8 +269,7 @@
|
||||
(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 (= {:text "ok" :error? false} r) "a repeated id is deleted once, not refused as unknown the second time")
|
||||
(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.
|
||||
@@ -367,27 +363,25 @@
|
||||
(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 (tu/todo-uuid 2) :title "buy milk" :done false}]
|
||||
(map #(select-keys % [:id :title :done]) (json (:text r)))))
|
||||
(is (= {:text "ok" :error? false} 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" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(is (= {:text "ok" :error? false} 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" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(is (= {:text "ok" :error? false} r) "still nothing to do: the now-empty server list was never asked")
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text (str "{\"deleted\":[\"" (tu/todo-uuid 1) "\"]}") :error? false} r))
|
||||
(is (= {:text "ok" :error? false} r))
|
||||
(is (= ["buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
@@ -417,10 +411,11 @@
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||
(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.
|
||||
;; It pressed the cell, as a user would; there is nothing left to say (see
|
||||
;; provider-for's tic-tac-toe assertions for the shape it takes in the state).
|
||||
(is (= {:text "ok" :error? false} r))
|
||||
(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))))
|
||||
(is (= :o (get-in @rf-db/app-db [:ttt :turn])) "it is O's turn now")
|
||||
(call "move_tic_tac_toe" {:cell 4 :player "o"})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "cell 4 is taken" :error? true} r))
|
||||
@@ -448,21 +443,72 @@
|
||||
(is (:error? r))
|
||||
(call "move_tic_tac_toe" {:cell 0 :player "o"})))
|
||||
(.then (fn [r]
|
||||
(is (= "x" (:turn (json (:text r)))))
|
||||
(is (= {:text "ok" :error? false} r))
|
||||
(is (= :x (get-in @rf-db/app-db [:ttt :turn])))
|
||||
;; The person moves X to 3 and O to 1; the agent's X then completes the middle row.
|
||||
(rf/dispatch-sync [::events/ttt-move 3])
|
||||
(rf/dispatch-sync [::events/ttt-move 1])
|
||||
(call "move_tic_tac_toe" {:cell 5 :player "x"})))
|
||||
(.then (fn [r]
|
||||
(is (= {:board ["o" "o" nil "x" "x" "x" nil nil nil]
|
||||
:status "won" :winner "x" :line [3 4 5]}
|
||||
(json (:text r))))
|
||||
(is (= {:text "ok" :error? false} r))
|
||||
(is (= [:o :o nil :x :x :x nil nil nil] (get-in @rf-db/app-db [:ttt :board])) "X won the middle row")
|
||||
(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" {})))
|
||||
(.then (fn [r]
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||
(is (= {:text "ok" :error? false} r))
|
||||
(is (= (vec (repeat 9 nil)) (get-in @rf-db/app-db [:ttt :board])))
|
||||
(is (= :x (get-in @rf-db/app-db [:ttt :turn])))
|
||||
(done))))))
|
||||
|
||||
;; --- State (tbd.ui.model-context's registry, filled from webmcp/state-providers) ---
|
||||
|
||||
(defn- provider-for
|
||||
"The `:provide` fn of the state provider registered for exactly `pages`."
|
||||
[pages]
|
||||
(:provide (first (filter #(= pages (:pages %)) webmcp/state-providers))))
|
||||
|
||||
(deftest calculator-state-provider-test
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (page-loaded)
|
||||
(.then (fn [_]
|
||||
(is (= {:x "" :y "" :result {:status :idle}} ((provider-for #{:calculator}))))
|
||||
(call "add" {:x 2 :y 3})))
|
||||
(.then (fn [_]
|
||||
(is (= {:x "2" :y "3" :result {:status :ok :x "2" :y "3" :sum 5}}
|
||||
((provider-for #{:calculator}))))
|
||||
(done))))))
|
||||
|
||||
(deftest tic-tac-toe-state-provider-test
|
||||
(open-page!)
|
||||
;; Raw, as tbd.ui.tictactoe keeps it (:x/:o, not the "x"/"o" strings get_tic_tac_toe's JSON gave:
|
||||
;; there is no JSON round trip here, only on the wire to the server).
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn :x} ((provider-for #{:tic-tac-toe})))
|
||||
"the same state get_tic_tac_toe used to hand back")
|
||||
(async done
|
||||
(-> (call "move_tic_tac_toe" {:cell 4 :player "x"})
|
||||
(.then (fn [_]
|
||||
(is (= {:board [nil nil nil nil :x nil nil nil nil] :status "in_progress" :turn :o}
|
||||
((provider-for #{:tic-tac-toe}))))
|
||||
(done))))))
|
||||
|
||||
(deftest todos-state-provider-test
|
||||
(testing "nothing until the todos have loaded (no login, or not fetched yet): not an empty list,
|
||||
which would read as \"there are none\""
|
||||
(is (nil? ((provider-for #{:todos})))))
|
||||
(tu/log-in!)
|
||||
(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
|
||||
(-> (call "list_todos" {}) ; waits for the load, the way logged-in-todos does
|
||||
(.then (fn [_]
|
||||
(is (= {:todos [{:id (tu/todo-uuid 1) :title "old" :done false}]} ((provider-for #{:todos}))))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [_]
|
||||
(is (= ["old" "buy milk"] (map :title (:todos ((provider-for #{:todos}))))))
|
||||
(done))))))
|
||||
|
||||
;; --- Prompts and resources ---
|
||||
|
||||
Reference in New Issue
Block a user