@@ -0,0 +1,40 @@
|
||||
(ns tbd.agent.prompt-test
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[tbd.agent.prompt :as prompt]))
|
||||
|
||||
(defn- flat
|
||||
"`text` on one line: the prompts are wrapped in the source, where they wrap is not the point."
|
||||
[text]
|
||||
(str/replace text #"\s+" " "))
|
||||
|
||||
(deftest chat-and-guide-share-the-instructions
|
||||
(testing "both tell the agent to act, to chain calls and to report only what a result says"
|
||||
(doseq [text (map flat [prompt/chat prompt/guide])]
|
||||
(is (str/includes? text "Act, don't offer"))
|
||||
(is (str/includes? text "in this same turn"))
|
||||
(is (str/includes? text "Work it out yourself:"))
|
||||
(is (str/includes? text "Chain calls without stopping"))
|
||||
(is (str/includes? text "results from an earlier message are marked out of date"))
|
||||
(is (str/includes? text "never go by an earlier result"))
|
||||
(is (str/includes? text "Say something was done only if a tool result says it was"))
|
||||
(is (str/includes? text "Tic-tac-toe: check whose turn it is"))
|
||||
(is (str/includes? text "Examples: "))))
|
||||
(testing "what only fits the chatbot is not said to an agent outside the page"
|
||||
(is (str/includes? prompt/chat "under your message"))
|
||||
(is (not (str/includes? prompt/guide "under your message")))
|
||||
(is (not (str/includes? prompt/guide "navigate there, then")) "an outside agent is offered every tool")))
|
||||
|
||||
(deftest task-prompt
|
||||
(is (str/starts-with? (prompt/task "add a todo") prompt/guide))
|
||||
(is (str/ends-with? (prompt/task "add a todo") "The user's request: add a todo"))
|
||||
(doseq [none [nil "" 5]]
|
||||
(is (str/includes? (prompt/task none) "Ask the user what they want") (pr-str none))
|
||||
(is (not (str/includes? (prompt/task none) "The user's request")) (pr-str none))))
|
||||
|
||||
(deftest tic-tac-toe-prompt
|
||||
(is (str/includes? (prompt/tic-tac-toe "x") "as X."))
|
||||
(is (str/includes? (prompt/tic-tac-toe "o") "as O."))
|
||||
(doseq [other [nil "" "z" :x]]
|
||||
(is (str/includes? (prompt/tic-tac-toe other) "as O.") (pr-str other)))
|
||||
(is (str/includes? (prompt/tic-tac-toe "x") "never move for the other side")))
|
||||
+102
-3
@@ -34,10 +34,15 @@
|
||||
(defn- stream-of ^InputStream [& parts]
|
||||
(ByteArrayInputStream. (.getBytes ^String (apply str parts) "UTF-8")))
|
||||
|
||||
(def ^:private quiet
|
||||
"A trace config that says nothing (see tbd.chat.trace/config-for): the tests' log is for failures."
|
||||
{:sink (fn [_]) :full-text? false})
|
||||
|
||||
(defn- config
|
||||
"A configured chat whose LLM server is `http`; `calls` (an atom) collects what it was sent."
|
||||
[calls http & {:as overrides}]
|
||||
(merge {:url "http://llm.test:1234" :timeout-seconds 30
|
||||
:trace quiet
|
||||
:http (fn [req] (swap! calls conj req) (http req))}
|
||||
overrides))
|
||||
|
||||
@@ -368,6 +373,100 @@
|
||||
(is (= [{:error "the LLM server's reply was cut off"}]
|
||||
(run (config (atom []) (fn [_] {:status 200 :body in}))))))))
|
||||
|
||||
;; --- Tracing: what the operator sees of a turn ---
|
||||
|
||||
(defn- traced
|
||||
"Runs a chat over `conversation` with `http`, tracing into an atom: [what the client was sent, entries]."
|
||||
([http] (traced http hello))
|
||||
([http conversation]
|
||||
(let [entries (atom [])
|
||||
result (run* (config (atom []) http :trace {:sink #(swap! entries conj %) :full-text? true}
|
||||
:model "qwen")
|
||||
conversation nil)]
|
||||
[result @entries])))
|
||||
|
||||
(defn- turn-end [entries] (first (filter #(= :turn-end (:event %)) entries)))
|
||||
|
||||
(deftest tracing-test
|
||||
(testing "a turn is logged when it starts, and when it ends, with the same ids"
|
||||
(let [[events entries] (traced (replying (reasoning "Let me think. ") (content "Hi") done))
|
||||
[start end] entries]
|
||||
(is (= [{:thinking "Let me think. "} {:content "Hi"} {:done true}] events))
|
||||
(is (= [:turn-start :turn-end] (map :event entries)))
|
||||
(is (= (select-keys start [:req :run :step]) (select-keys end [:req :run :step])))
|
||||
(is (= {:model "qwen" :messages 1 :tools 0 :step 0 :run-calls 0} (select-keys start [:model :messages :tools :step :run-calls])))
|
||||
(is (= {:outcome :done :thinking-chars 14 :content-chars 2 :events 2}
|
||||
(select-keys end [:outcome :thinking-chars :content-chars :events])))
|
||||
(is (every? #(number? (get end %)) [:duration-ms :ttft-ms :think-ms]))
|
||||
(is (= "Let me think. " (:reasoning end)))
|
||||
(is (= "Hi" (:reply end)))
|
||||
(is (= hello (:input start)))))
|
||||
(testing "the calls the model asked for are in the outcome"
|
||||
(let [[_ entries] (traced (replying (call-piece 0 {:id "a" :name "add" :arguments "{\"x\":1}"}) done))]
|
||||
(is (= :tool-calls (:outcome (turn-end entries))))
|
||||
(is (= [{:name "add" :args "{\"x\":1}"}] (:calls (turn-end entries))))))
|
||||
(testing "why the model stopped, and what it cost, are logged, and are not the client's business"
|
||||
(let [[events entries] (traced (replying (content "Hi")
|
||||
(str "data: " (json {:choices [{:index 0 :delta {} :finish_reason "length"}]}) "\n\n")
|
||||
(str "data: " (json {:choices [] :usage {:prompt_tokens 10 :completion_tokens 5
|
||||
:total_tokens 15 :other "x"}}) "\n\n")
|
||||
done))
|
||||
end (turn-end entries)]
|
||||
(is (= [{:content "Hi"} {:done true}] events))
|
||||
(is (= {:finish-reason "length" :prompt-tokens 10 :completion-tokens 5 :flags [:truncated]}
|
||||
(select-keys end [:finish-reason :prompt-tokens :completion-tokens :flags])))))
|
||||
(testing "the failures of the LLM are outcomes too"
|
||||
(is (= {:outcome :error :error "the LLM server sent no reply"}
|
||||
(select-keys (turn-end (second (traced (replying done)))) [:outcome :error])))
|
||||
(let [end (turn-end (second (traced (fn [_] {:status 200 :body (cut-off (content "Hel"))}))))]
|
||||
(is (= {:outcome :error :error "the LLM server's reply was cut off"} (select-keys end [:outcome :error])))
|
||||
(is (str/includes? (:detail end) "connection reset")))
|
||||
(let [entries (atom [])
|
||||
{:keys [ok]} (chat/start (config (atom []) (fn [_] {:status 200 :body (silent)}) :timeout-seconds 1
|
||||
:trace {:sink #(swap! entries conj %)}) hello)]
|
||||
(ok identity)
|
||||
(is (= "1s of silence" (:detail (turn-end @entries))))))
|
||||
(testing "a turn that never got a reply says why"
|
||||
(let [entries (atom [])
|
||||
result (chat/start (config (atom []) (fn [_] {:status 500 :body (stream-of "oops")})
|
||||
:trace {:sink #(swap! entries conj %)}) hello)
|
||||
end (turn-end @entries)]
|
||||
(is (= :upstream (:kind result)))
|
||||
(is (= [:turn-start :turn-end] (map :event @entries)))
|
||||
(is (= {:outcome :upstream-error :error "the LLM server answered HTTP 500" :detail "body: oops"}
|
||||
(select-keys end [:outcome :error :detail])))))
|
||||
(testing "a client that goes away ends the turn as aborted, once"
|
||||
(let [entries (atom [])
|
||||
{:keys [ok]} (chat/start (config (atom []) (replying (content "a") (content "b") done)
|
||||
:trace {:sink #(swap! entries conj %)}) hello)]
|
||||
(is (thrown? IOException (ok (fn [_] (throw (IOException. "broken pipe"))))))
|
||||
(is (= [:aborted] (map :outcome (filter #(= :turn-end (:event %)) @entries))))))
|
||||
(testing "a tracer that breaks doesn't break the chat"
|
||||
(is (= [{:content "Hi"} {:done true}]
|
||||
(run (config (atom []) (replying (content "Hi") done)
|
||||
:trace {:sink (fn [_] (throw (IllegalStateException. "disk full")))})))))
|
||||
(testing "without a trace config the turn is still logged, to the log"
|
||||
(let [out (with-out-str (run (dissoc (config (atom []) (replying (content "Hi") done)) :trace)))]
|
||||
(is (str/includes? out "[tbd] chat turn-start"))
|
||||
(is (str/includes? out "[tbd] chat turn-end")))))
|
||||
|
||||
(deftest tracing-the-tool-loop-test
|
||||
(let [call {:role "assistant" :content "" :tool-calls [{:id "c" :name "get_tic_tac_toe" :arguments "{}"}]}
|
||||
result {:role "tool" :tool-call-id "c" :content "board"}
|
||||
conversation (fn [n] (into [{:role "user" :content "play"}] (take (* 2 n) (cycle [call result]))))
|
||||
starts (fn [n] (filter #(#{:turn-start :loop-suspect} (:event %))
|
||||
(second (traced (replying (content "x") done) (conversation n)))))]
|
||||
(testing "the turns of one run share a run id, and count their steps"
|
||||
(let [[a b] (map (comp first starts) [1 2])]
|
||||
(is (= (:run a) (:run b)))
|
||||
(is (= [1 2] [(:step a) (:step b)]))
|
||||
(is (= [1 2] [(:run-calls a) (:run-calls b)]))))
|
||||
(testing "the same call over and over is a suspect"
|
||||
(is (= [:turn-start] (map :event (starts 2))))
|
||||
(let [[_ suspect] (starts 3)]
|
||||
(is (= {:event :loop-suspect :kind :repeated-call :tool "get_tic_tac_toe" :count 3 :args "{}"}
|
||||
(select-keys suspect [:event :kind :tool :count :args])))))))
|
||||
|
||||
;; --- The default HTTP client, against a real server on loopback ---
|
||||
|
||||
(defn- llm-server
|
||||
@@ -399,7 +498,7 @@
|
||||
(testing "an unreachable server is an error result"
|
||||
;; Port 1 on loopback: nothing listens there, so the connection is refused at once.
|
||||
(is (= {:error "the LLM server can't be reached" :kind :upstream}
|
||||
(chat/start {:url "http://127.0.0.1:1" :timeout-seconds 5 :http chat/java-http} hello))))
|
||||
(chat/start {:url "http://127.0.0.1:1" :timeout-seconds 5 :trace quiet :http chat/java-http} hello))))
|
||||
(testing "a plain HTTP/1.1 request, with no upgrade to HTTP/2 offered, that gets the whole reply"
|
||||
(let [seen (atom nil)]
|
||||
(llm-server
|
||||
@@ -412,7 +511,7 @@
|
||||
(.close ex))
|
||||
(fn [url]
|
||||
(is (= [{:content "Hello "} {:content "from the LLM"} {:done true}]
|
||||
(run {:url url :model "qwen" :timeout-seconds 10 :http chat/java-http})))))
|
||||
(run {:url url :model "qwen" :timeout-seconds 10 :trace quiet :http chat/java-http})))))
|
||||
(is (= "POST" (:method @seen)))
|
||||
(is (= "application/json" (:content-type @seen)))
|
||||
(is (= {:model "qwen" :stream true :messages hello} (sent-body (:body @seen))))))
|
||||
@@ -430,6 +529,6 @@
|
||||
(.close ex))
|
||||
(fn [url]
|
||||
(is (= [{:content "first"} {:content "second"} {:done true}]
|
||||
(run {:url url :timeout-seconds 10 :http chat/java-http}
|
||||
(run {:url url :timeout-seconds 10 :trace quiet :http chat/java-http}
|
||||
#(when (= {:content "first"} %) (.countDown first-arrived)))))))
|
||||
(is (true? @released?) "the first piece was delivered while the server was still replying"))))
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
(ns tbd.chat.trace-test
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[muuntaja.core :as m]
|
||||
[tbd.chat.trace :as trace])
|
||||
(:import (java.io File)))
|
||||
|
||||
(defn- collecting
|
||||
"A trace config that keeps its entries in `entries`, an atom."
|
||||
[entries & {:as opts}]
|
||||
(merge {:sink #(swap! entries conj %) :full-text? false} opts))
|
||||
|
||||
(defn- suspects [entries] (filter #(= :loop-suspect (:event %)) entries))
|
||||
|
||||
(defn- call [id name args] {:id id :name name :arguments args})
|
||||
|
||||
(defn- assistant [& calls] {:role "assistant" :content "" :tool-calls (vec calls)})
|
||||
(defn- result [id] {:role "tool" :tool-call-id id :content "ok"})
|
||||
|
||||
(deftest run-summary-test
|
||||
(let [ask {:role "user" :content "add milk"}
|
||||
list-call (call "1" "list_todos" "{}")]
|
||||
(testing "a conversation that has just begun"
|
||||
(is (= {:step 0 :run-calls 0 :top-call nil :input [ask]}
|
||||
(dissoc (trace/run-summary [ask]) :run))))
|
||||
(testing "the turns of a run count the assistant's messages since the user's, and their calls"
|
||||
(let [s (trace/run-summary [ask (assistant list-call) (result "1") (assistant (call "2" "add" "{}") (call "3" "add" "{}"))
|
||||
(result "2") (result "3")])]
|
||||
(is (= {:step 2 :run-calls 3 :top-call {:tool "add" :args "{}" :count 2}}
|
||||
(select-keys s [:step :run-calls :top-call])))
|
||||
(is (= ask (first (:input s))))
|
||||
(is (= 6 (count (:input s))))))
|
||||
(testing "the run id is that of the user's last message: the same for every turn of a run"
|
||||
(let [a (trace/run-summary [ask])
|
||||
b (trace/run-summary [ask (assistant list-call) (result "1")])
|
||||
c (trace/run-summary [{:role "user" :content "something else"}])]
|
||||
(is (re-matches #"[0-9a-f]{8}" (:run a)))
|
||||
(is (= (:run a) (:run b)))
|
||||
(is (not= (:run a) (:run c)))))
|
||||
(testing "an earlier run's calls are not this run's"
|
||||
(let [s (trace/run-summary [ask (assistant list-call) (result "1") {:role "assistant" :content "Done."}
|
||||
{:role "user" :content "again"}])]
|
||||
(is (= {:step 0 :run-calls 0} (select-keys s [:step :run-calls])))))
|
||||
(testing "the same tool with other arguments is another call"
|
||||
(is (= 1 (:count (:top-call (trace/run-summary [ask (assistant (call "1" "add" "{\"x\":1}") (call "2" "add" "{\"x\":2}"))]))))))
|
||||
(testing "a conversation with no user message, and none at all"
|
||||
(is (= "-" (:run (trace/run-summary [{:role "assistant" :content "hi"}]))))
|
||||
(is (= {:run "-" :step 0 :run-calls 0} (select-keys (trace/run-summary []) [:run :step :run-calls]))))))
|
||||
|
||||
(deftest begin-test
|
||||
(testing "the start of a turn says what it is about, without the words of the conversation"
|
||||
(let [entries (atom [])]
|
||||
(trace/begin! (collecting entries)
|
||||
{:messages [{:role "user" :content "hello"}] :tools [{:name "add"} {:name "sub"}]
|
||||
:page "todos" :model "qwen"})
|
||||
(let [[start :as all] @entries]
|
||||
(is (= 1 (count all)))
|
||||
(is (= {:event :turn-start :page "todos" :model "qwen" :messages 1 :tools 2 :prompt-chars 5 :step 0}
|
||||
(select-keys start [:event :page :model :messages :tools :prompt-chars :step])))
|
||||
(is (re-matches #"[0-9a-f]{8}" (:req start)))
|
||||
(is (not (contains? start :input))))))
|
||||
(testing "with the full text on, it has the run's messages"
|
||||
(let [entries (atom [])
|
||||
messages [{:role "user" :content "old"} {:role "assistant" :content "reply"} {:role "user" :content "new"}]]
|
||||
(trace/begin! (collecting entries :full-text? true) {:messages messages})
|
||||
(is (= [{:role "user" :content "new"}] (:input (first @entries))))))
|
||||
(testing "every turn has its own req id"
|
||||
(let [entries (atom [])]
|
||||
(dotimes [_ 3] (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]}))
|
||||
(is (= 3 (count (set (map :req @entries)))))))
|
||||
(testing "a call made three times in a run is a suspect, twice is not"
|
||||
(let [ask {:role "user" :content "go"}
|
||||
messages (fn [n] (into [ask] (mapcat (fn [i] [(assistant (call (str i) "look" "{}")) (result (str i))])) (range n)))
|
||||
entries (atom [])]
|
||||
(trace/begin! (collecting entries) {:messages (messages 2)})
|
||||
(is (empty? (suspects @entries)))
|
||||
(trace/begin! (collecting entries) {:messages (messages 3)})
|
||||
(is (= [{:kind :repeated-call :tool "look" :args "{}" :count 3}]
|
||||
(map #(select-keys % [:kind :tool :args :count]) (suspects @entries)))))))
|
||||
|
||||
(deftest observe-test
|
||||
(testing "reasoning that says the same sentence over and over is a suspect, at 4 times and each doubling"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(dotimes [_ 16]
|
||||
(trace/observe! t {:thinking "Wait, let me check the board again. "}))
|
||||
(is (= [4 8 16] (map :count (suspects @entries))))
|
||||
(is (every? #(= :repeated-thinking (:kind %)) (suspects @entries)))
|
||||
(is (= "Wait, let me check the board again." (:text (first (suspects @entries)))))))
|
||||
(testing "a sentence is the same whatever its case and spacing, and however the pieces of it came"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(doseq [piece ["Wait, let me check" " the board AGAIN." "\n" "wait, let me check the board again.\n"
|
||||
"Wait, let me check the " "board again! " "Wait, let me check the board again?\n\n"]]
|
||||
(trace/observe! t {:thinking piece}))
|
||||
(is (= [4] (map :count (suspects @entries))))))
|
||||
(testing "short sentences, and different ones, are no repetition"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(dotimes [i 20]
|
||||
(trace/observe! t {:thinking "Hmm. "})
|
||||
(trace/observe! t {:thinking (str "Step " i " of working out which of the many todos is the one. ")}))
|
||||
(is (empty? (suspects @entries)))))
|
||||
(testing "reasoning that goes on and on is a suspect at 8000 characters, and at each doubling"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(dotimes [i 17]
|
||||
(trace/observe! t {:thinking (str (format "%03d" i) (apply str (repeat 997 \x)))}))
|
||||
(is (= [8000 16000] (map :chars (filter #(= :long-reasoning (:kind %)) (suspects @entries)))))))
|
||||
(testing "a reply and calls are not reasoning"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(dotimes [_ 10]
|
||||
(trace/observe! t {:content "Wait, let me check the board again. "}))
|
||||
(is (empty? (suspects @entries))))))
|
||||
|
||||
(deftest finish-test
|
||||
(testing "the end of a turn sums it up, and only the first end counts"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(trace/observe! t {:thinking "hm"})
|
||||
(trace/observe! t {:thinking "m"})
|
||||
(trace/observe! t {:content "Hi"})
|
||||
(trace/observe! t {:finish-reason "stop" :usage {:prompt_tokens 7 :completion_tokens 3}})
|
||||
(trace/finish! t {:outcome :done})
|
||||
(trace/finish! t {:outcome :aborted})
|
||||
(let [ends (filter #(= :turn-end (:event %)) @entries)
|
||||
end (first ends)]
|
||||
(is (= 1 (count ends)))
|
||||
(is (= {:outcome :done :events 4 :thinking-chars 3 :content-chars 2 :finish-reason "stop"
|
||||
:prompt-tokens 7 :completion-tokens 3 :flags []}
|
||||
(select-keys end [:outcome :events :thinking-chars :content-chars :finish-reason
|
||||
:prompt-tokens :completion-tokens :flags])))
|
||||
(is (every? #(and (number? %) (>= % 0)) ((juxt :duration-ms :ttft-ms :think-ms) end)))
|
||||
(is (not (contains? end :reasoning)) "the text stays out unless it is asked for"))))
|
||||
(testing "the flags of a turn are what went wrong in it"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(dotimes [_ 4] (trace/observe! t {:thinking "Wait, let me check the board again. "}))
|
||||
(trace/observe! t {:finish-reason "length"})
|
||||
(trace/finish! t {:outcome :done})
|
||||
(is (= [:repeated-thinking :truncated] (:flags (last @entries))))))
|
||||
(testing "a long call is cut, and the time spent thinking is the time until the answer"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(trace/observe! t {:thinking "hm"})
|
||||
(trace/observe! t {:call-deltas [{:index 0 :name "add"}]})
|
||||
(trace/finish! t {:outcome :tool-calls :calls [(call "a" "add" (apply str (repeat 2000 \y)))]})
|
||||
(let [end (last @entries)]
|
||||
(is (= [{:name "add" :args (str (apply str (repeat 500 \y)) "…")}] (:calls end)))
|
||||
(is (<= (:think-ms end) (:duration-ms end))))))
|
||||
(testing "with the full text on, the turn ends with what the model said"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries :full-text? true) {:messages [{:role "user" :content "x"}]})]
|
||||
(trace/observe! t {:thinking "One. "})
|
||||
(trace/observe! t {:thinking "Two."})
|
||||
(trace/observe! t {:content "Three"})
|
||||
(trace/finish! t {:outcome :done})
|
||||
(is (= {:reasoning "One. Two." :reply "Three"} (select-keys (last @entries) [:reasoning :reply])))))
|
||||
(testing "a failure has its words"
|
||||
(let [entries (atom [])
|
||||
t (trace/begin! (collecting entries) {:messages [{:role "user" :content "x"}]})]
|
||||
(trace/finish! t {:outcome :error :error "the LLM server's reply was cut off" :detail "reset"})
|
||||
(is (= {:outcome :error :error "the LLM server's reply was cut off" :detail "reset"}
|
||||
(select-keys (last @entries) [:outcome :error :detail]))))))
|
||||
|
||||
(deftest render-test
|
||||
(let [line (trace/render {:event :turn-end :req "ab12cd34" :run "00ff00ff" :step 2 :outcome :tool-calls
|
||||
:duration-ms 1200 :thinking-chars 0 :error "it broke" :detail nil :flags [:truncated :repeated-thinking]
|
||||
:calls [{:name "add" :args "{\"x\":1}"}] :reasoning "secret words" :reply "more" :input [{:role "user"}]
|
||||
:extra []})]
|
||||
(testing "one line, the fields in a fixed order, the values readable"
|
||||
(is (= (str "[tbd] chat turn-end req=ab12cd34 run=00ff00ff step=2 outcome=tool-calls duration-ms=1200 thinking-chars=0"
|
||||
" calls=\"add{\\\"x\\\":1}\" flags=truncated,repeated-thinking error=\"it broke\"")
|
||||
line)))
|
||||
(testing "the text of the conversation is for the file only"
|
||||
(is (not (str/includes? line "secret")))
|
||||
(is (not (str/includes? line "more")))))
|
||||
(testing "a long argument is cut in the line"
|
||||
(is (< (count (trace/render {:event :turn-end :calls [{:name "add" :args (apply str (repeat 1000 \z))}]})) 250))))
|
||||
|
||||
(defn- temp-file ^File []
|
||||
(doto (File/createTempFile "chat-trace" ".jsonl") .delete .deleteOnExit))
|
||||
|
||||
(deftest sink-test
|
||||
(testing "without a file, an entry is a line of the log"
|
||||
(is (= "[tbd] chat turn-start req=a page=todos\n"
|
||||
(with-out-str ((trace/sink nil) {:event :turn-start :req "a" :page "todos"})))))
|
||||
(testing "with a file, it is also a line of JSON there, with everything in it"
|
||||
(let [file (temp-file)
|
||||
sink (trace/sink (str file))
|
||||
out (with-out-str
|
||||
(sink {:event :turn-end :req "a" :outcome :done :reasoning "long \"thoughts\"\nover lines" :flags [:truncated]})
|
||||
(sink {:event :turn-start :req "b"}))
|
||||
lines (str/split-lines (slurp file))
|
||||
[end start] (map #(m/decode "application/json" %) lines)]
|
||||
(is (not (str/includes? out "thoughts")) "the log has no text")
|
||||
(is (= 2 (count lines)))
|
||||
(is (= {:event "turn-end" :req "a" :outcome "done" :reasoning "long \"thoughts\"\nover lines" :flags ["truncated"]}
|
||||
(dissoc end :ts)))
|
||||
(is (= "turn-start" (:event start)))
|
||||
(is (some? (java.time.Instant/parse (:ts end))))))
|
||||
(testing "the file's folders are made"
|
||||
(let [dir (doto (File/createTempFile "chat-trace" "") .delete)
|
||||
file (File. dir "deeper/trace.jsonl")]
|
||||
(with-out-str ((trace/sink (str file)) {:event :turn-start :req "a"}))
|
||||
(is (.exists file))
|
||||
(.delete file) (.delete (.getParentFile file)) (.delete dir)))
|
||||
(testing "a file that can't be written to is complained about, and nothing is thrown"
|
||||
(let [dir (doto (File/createTempFile "chat-trace" "") .delete .mkdirs)
|
||||
out (try (with-out-str ((trace/sink (str dir)) {:event :turn-start :req "a"}))
|
||||
(finally (.delete dir)))]
|
||||
(is (str/includes? out "[tbd] chat turn-start req=a"))
|
||||
(is (str/includes? out "can't write the trace file")))))
|
||||
|
||||
(deftest config-for-test
|
||||
(testing "no file: the log only, and no full text"
|
||||
(is (= {:full-text? false :file nil} (select-keys (trace/config-for nil) [:full-text? :file])))
|
||||
(is (fn? (:sink (trace/config-for nil)))))
|
||||
(testing "a file: the full text goes there"
|
||||
(let [file (str (temp-file))]
|
||||
(is (= {:full-text? true :file file} (select-keys (trace/config-for file) [:full-text? :file]))))))
|
||||
@@ -99,8 +99,11 @@
|
||||
(is (= {:x "First addend" :y "Second addend"}
|
||||
(update-vals (:properties add) :description)))
|
||||
(is (= ["number" "number"] (map :type (vals (:properties add))))))
|
||||
(is (= {:type "object" :required ["id"] :properties {:id {:type "integer" :description "Todo id"}}}
|
||||
(input-schema "get_todo")))
|
||||
(let [get-todo (input-schema "get_todo")]
|
||||
(is (= ["id"] (:required get-todo)))
|
||||
(is (= "string" (get-in get-todo [:properties :id :type])) "an id is a string, not a number")
|
||||
(is (re-find #"UUID" (get-in get-todo [:properties :id :description])))
|
||||
(is (re-find (re-pattern (get-in get-todo [:properties :id :pattern])) (str (random-uuid)))))
|
||||
(is (= ["id"] (:required (input-schema "update_todo"))) "title and done are optional")
|
||||
(is (= #{"id" "title" "done"} (set (map name (keys (:properties (input-schema "update_todo"))))))))
|
||||
(testing "and ones with no arguments say so"
|
||||
@@ -256,16 +259,18 @@
|
||||
|
||||
(deftest tool-arguments-are-validated-test
|
||||
(let [{:keys [app alice]} (fixture)
|
||||
todo-id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b"
|
||||
sid (initialize app alice)
|
||||
error (fn [tool arguments]
|
||||
(let [resp (call-tool app alice sid tool arguments)]
|
||||
(is (true? (get-in resp [:result :isError])) (str tool " " (pr-str arguments)))
|
||||
(text resp)))]
|
||||
(testing "arguments of the wrong type are turned away, which plumcp alone doesn't do"
|
||||
(is (= "invalid arguments: {:id [\"should be an integer\"]}" (error "get_todo" {:id "abc"})))
|
||||
(is (= "invalid arguments: {:id [\"should be a UUID\"]}" (error "get_todo" {:id "abc"})))
|
||||
(is (= "invalid arguments: {:id [\"should be a UUID\"]}" (error "get_todo" {:id 1})) "todo ids are not integers")
|
||||
(is (= "invalid arguments: {:title [\"should be a string\"]}" (error "create_todo" {:title 5})))
|
||||
(is (= "invalid arguments: {:done [\"should be a boolean\"]}" (error "update_todo" {:id 1 :done "yes"})))
|
||||
(is (= "invalid arguments: {:done [\"should be a boolean\"]}" (error "update_todo" {:id todo-id :done "yes"})))
|
||||
(is (= "invalid arguments: {:x [\"should be an integer\" \"should be a double\"]}" (error "add" {:x "2" :y 3}))))
|
||||
(testing "the tools and the HTTP API validate against the same schemas"
|
||||
(is (false? (malli/validate todo-schema/UpdateTodo {:id 1 :done "yes"})))
|
||||
(is (true? (malli/validate todo-schema/UpdateTodo {:id 1 :done true}))))))
|
||||
(is (false? (malli/validate todo-schema/UpdateTodo {:id todo-id :done "yes"})))
|
||||
(is (true? (malli/validate todo-schema/UpdateTodo {:id todo-id :done true}))))))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns tbd.oauth.db-test
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[next.jdbc :as jdbc]
|
||||
[tbd.oauth.db :as db])
|
||||
(:import (java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)))
|
||||
@@ -55,6 +56,7 @@
|
||||
(is (nil? (db/find-grant-by-access ds "refresh-1" 1000)) "a refresh token isn't an access token"))
|
||||
(testing "found by refresh token"
|
||||
(is (= 7 (:user-id (db/find-grant-by-refresh ds "refresh-1"))))
|
||||
(is (uuid? (parse-uuid (:id (db/find-grant-by-refresh ds "refresh-1")))) "a grant's id is a UUID")
|
||||
(is (nil? (db/find-grant-by-refresh ds "access-1"))))
|
||||
(testing "rotating replaces both tokens, and works once per refresh token"
|
||||
(let [id (:id (db/find-grant-by-refresh ds "refresh-1"))
|
||||
@@ -88,3 +90,31 @@
|
||||
(is (nil? (db/find-grant-by-refresh ds "refresh-2")) "one that can't is dropped")
|
||||
(is (nil? (db/take-code! ds "expired")))
|
||||
(is (some? (db/take-code! ds "pending")))))
|
||||
|
||||
(deftest integer-grant-ids-are-migrated-test
|
||||
(testing "grants from before ids were UUIDs survive, under new ids, and still work"
|
||||
(let [path (temp-db-path)
|
||||
_ (io/make-parents path)
|
||||
old (jdbc/get-datasource {:dbtype "sqlite" :dbname path})]
|
||||
(jdbc/execute! old ["CREATE TABLE grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
login TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
access_hash TEXT NOT NULL UNIQUE,
|
||||
access_expires_at INTEGER NOT NULL,
|
||||
refresh_hash TEXT NOT NULL UNIQUE,
|
||||
refresh_expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL)"])
|
||||
(jdbc/execute! old ["INSERT INTO grants (client_id, user_id, login, scope, access_hash, access_expires_at,
|
||||
refresh_hash, refresh_expires_at, created_at)
|
||||
VALUES ('c1', 7, 'alice', 'mcp', 'access-1', 2000, 'refresh-1', 9000, 1000)"])
|
||||
(let [ds (db/open path)
|
||||
migrated (db/find-grant-by-access ds "access-1" 1000)]
|
||||
(is (= {:client-id "c1" :user-id 7 :login "alice" :scope "mcp" :refresh-hash "refresh-1"}
|
||||
(select-keys migrated [:client-id :user-id :login :scope :refresh-hash])))
|
||||
(is (uuid? (parse-uuid (:id migrated))))
|
||||
(is (some? (db/rotate-grant! ds (:id migrated) "refresh-1"
|
||||
{:access-hash "access-2" :access-expires-at 3000
|
||||
:refresh-hash "refresh-2" :refresh-expires-at 9500})))))))
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
(todo/new-todo 7 " buy milk " 1000)))
|
||||
(is (= {:error "title must not be blank"} (todo/new-todo 7 " " 1000))))
|
||||
|
||||
(def stored {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000})
|
||||
(def stored {:id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b" :owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000})
|
||||
|
||||
(deftest apply-changes-test
|
||||
(testing "title"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns tbd.todo.db-test
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[next.jdbc :as jdbc]
|
||||
[tbd.todo.db :as db])
|
||||
(:import (java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)))
|
||||
@@ -38,6 +39,7 @@
|
||||
id (:id saved)]
|
||||
(testing "insert returns the stored todo with an id"
|
||||
(is (= (assoc (todo 7 "buy milk") :id id) saved))
|
||||
(is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" id) "a UUID, not a number")
|
||||
(is (not= id (:id (db/insert! ds (todo 7 "walk dog"))))))
|
||||
(testing "find and list"
|
||||
(is (= saved (db/find-todo ds 7 id)))
|
||||
@@ -47,9 +49,10 @@
|
||||
(db/update! ds 7 (assoc saved :title "buy oat milk" :done true :updated-at 2000))))
|
||||
(is (= true (:done (db/find-todo ds 7 id)))))
|
||||
(testing "missing ids"
|
||||
(is (nil? (db/find-todo ds 7 999)))
|
||||
(is (nil? (db/update! ds 7 (assoc saved :id 999))))
|
||||
(is (false? (db/delete! ds 7 999))))
|
||||
(let [missing (str (random-uuid))]
|
||||
(is (nil? (db/find-todo ds 7 missing)))
|
||||
(is (nil? (db/update! ds 7 (assoc saved :id missing))))
|
||||
(is (false? (db/delete! ds 7 missing)))))
|
||||
(testing "delete"
|
||||
(is (true? (db/delete! ds 7 id)))
|
||||
(is (nil? (db/find-todo ds 7 id)))
|
||||
@@ -67,3 +70,39 @@
|
||||
(is (nil? (db/update! ds 8 (assoc mine :title "hijacked"))))
|
||||
(is (false? (db/delete! ds 8 (:id mine))))
|
||||
(is (= mine (db/find-todo ds 7 (:id mine)))))))
|
||||
|
||||
(deftest list-order-test
|
||||
(testing "todos come oldest first, though their ids, being random, say nothing of the order"
|
||||
(let [ds (db/open (temp-db-path))
|
||||
titles (mapv #(str "todo " %) (range 20))]
|
||||
(doseq [title titles] (db/insert! ds (todo 7 title)))
|
||||
(db/insert! ds (assoc (todo 7 "the newest") :created-at 5000))
|
||||
(db/insert! ds (assoc (todo 7 "the oldest") :created-at 1))
|
||||
(is (= (concat ["the oldest"] titles ["the newest"]) (map :title (db/list-todos ds 7)))))))
|
||||
|
||||
(deftest integer-ids-are-migrated-test
|
||||
(testing "a database from before ids were UUIDs keeps its todos, in order, under new ids"
|
||||
(let [path (temp-db-path)
|
||||
_ (io/make-parents path)
|
||||
old (jdbc/get-datasource {:dbtype "sqlite" :dbname path})]
|
||||
(jdbc/execute! old ["CREATE TABLE todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL)"])
|
||||
(jdbc/execute! old ["CREATE INDEX todos_by_owner ON todos (owner_id, id)"])
|
||||
(doseq [[owner title done] [[7 "first" 0] [8 "theirs" 0] [7 "second" 1]]]
|
||||
(jdbc/execute! old ["INSERT INTO todos (owner_id, title, done, created_at, updated_at) VALUES (?, ?, ?, 1000, 1000)"
|
||||
owner title done]))
|
||||
(let [ds (db/open path)
|
||||
mine (db/list-todos ds 7)]
|
||||
(is (= [["first" false] ["second" true]] (map (juxt :title :done) mine)))
|
||||
(is (every? (comp uuid? parse-uuid :id) mine))
|
||||
(is (= ["theirs"] (map :title (db/list-todos ds 8))))
|
||||
(testing "and is only migrated once"
|
||||
(is (= mine (db/list-todos (db/open path) 7))))
|
||||
(testing "and works as a new one does"
|
||||
(db/insert! ds (todo 7 "third"))
|
||||
(is (= ["first" "second" "third"] (map :title (db/list-todos ds 7)))))))))
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{:keys [id] :as created} (:body resp)]
|
||||
(is (= 201 (:status resp)))
|
||||
(is (= {:owner-id 7 :title "buy milk" :done false} (select-keys created [:owner-id :title :done])))
|
||||
(is (pos? id))
|
||||
(is (uuid? (parse-uuid id)))
|
||||
(testing "get"
|
||||
(is (= created (:body (request app :get (str todos "/" id) cookie)))))
|
||||
(testing "patch"
|
||||
@@ -79,9 +79,10 @@
|
||||
(doseq [body ["{}" "{\"title\":\" \"}" "{\"done\":\"yes\"}" "{\"id\":99}"]]
|
||||
(is (= 400 (:status (request app :patch one cookie body))) body))
|
||||
(is (= 400 (:status (request app :patch one cookie))) "no body"))
|
||||
(testing "a non-numeric id"
|
||||
(doseq [method [:get :patch :delete]]
|
||||
(is (= 400 (:status (request app method (str todos "/abc") cookie "{\"done\":true}"))) (str method))))
|
||||
(testing "an id that is not a UUID (an integer, as todos used to have, included)"
|
||||
(doseq [method [:get :patch :delete]
|
||||
id ["abc" "1"]]
|
||||
(is (= 400 (:status (request app method (str todos "/" id) cookie "{\"done\":true}"))) (str method " " id))))
|
||||
(is (= [created] (:todos (:body (request app :get todos cookie)))) "nothing above changed the todo")))
|
||||
|
||||
(deftest per-user-scoping-test
|
||||
@@ -98,7 +99,7 @@
|
||||
(is (= ["alice's"] (map :title (:todos (:body (request alice-app :get todos alice))))))
|
||||
(is (= ["bob's"] (map :title (:todos (:body (request bob-app :get todos bob)))))))
|
||||
(testing "someone else's todo is indistinguishable from a missing one"
|
||||
(let [missing (request bob-app :get (str todos "/999999") bob)]
|
||||
(let [missing (request bob-app :get (str todos "/" (random-uuid)) bob)]
|
||||
(doseq [[method body] [[:get nil] [:patch "{\"title\":\"hijacked\"}"] [:delete nil]]]
|
||||
(let [resp (request bob-app method one bob body)]
|
||||
(is (= 404 (:status resp)) (str method))
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
id (:id created)]
|
||||
(testing "create trims the title, makes it the user's and returns it stored"
|
||||
(is (= {:owner-id 7 :title "buy milk" :done false} (select-keys created [:owner-id :title :done])))
|
||||
(is (integer? id)))
|
||||
(is (uuid? (parse-uuid id))))
|
||||
(is (= {:ok created} (service/find-todo ds alice id)))
|
||||
(is (= {:ok [created]} (service/list-todos ds alice)))
|
||||
(testing "update changes the title and/or done"
|
||||
|
||||
Reference in New Issue
Block a user