Compare commits
8 Commits
7f0e58240d
...
ag-ui
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ef6ec077 | |||
| 187252177c | |||
| bf38f7b1be | |||
| a53f0c3621 | |||
| 10f4ca654b | |||
| 47f6cbf2d1 | |||
| 1926432fc6 | |||
| 8799c1e14c |
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
target
|
**/target
|
||||||
.cpcache
|
.cpcache
|
||||||
.git
|
.git
|
||||||
node_modules
|
**/node_modules
|
||||||
.shadow-cljs
|
**/.shadow-cljs
|
||||||
resources/public/js
|
resources/public/js
|
||||||
.env
|
.env
|
||||||
data
|
data
|
||||||
|
|||||||
+26
-9
@@ -25,16 +25,33 @@ SESSION_SECRET=
|
|||||||
# Empty = nobody can.
|
# Empty = nobody can.
|
||||||
ADMIN_USERS=
|
ADMIN_USERS=
|
||||||
|
|
||||||
# --- Todo database --------------------------------------------------------
|
# --- Database -----------------------------------------------------------------
|
||||||
# SQLite file for the todo group; the directory is created on first use. In Docker, mount a
|
# The one SQLite file the app keeps everything in: the todos, and the MCP server's OAuth state
|
||||||
# volume here or the todos vanish with the container.
|
# (registered clients, and the hashed tokens of every client a user has approved). The directory
|
||||||
# TODO_DB_PATH=data/todo.db
|
# is created on first use. In Docker, mount a volume here or the todos vanish with the container
|
||||||
|
# and every MCP client has to be approved again after a restart. BASE_URL (above) must be the
|
||||||
|
# address MCP clients use.
|
||||||
|
# DB_PATH=data/tbd.db
|
||||||
|
|
||||||
# --- MCP OAuth database -----------------------------------------------------
|
# --- Agent (LM Studio, via LangChain4j) ---------------------------------------
|
||||||
# SQLite file for the MCP server's OAuth state: registered clients, and the tokens (hashed)
|
# The agent (tbd.agent.core, a LangChain4j streaming chat model) talks to an LM Studio server.
|
||||||
# of every client a user has approved. In Docker, mount a volume here too, or every MCP client
|
# Two surfaces use it: the chat widget in the UI (the green square, bottom left; POST /api/chat, an
|
||||||
# has to be approved again after a restart. BASE_URL (above) must be the address MCP clients use.
|
# AG-UI run, logged-in users only) and the A2A server for other agents (POST /a2a, the same OAuth
|
||||||
# OAUTH_DB_PATH=data/oauth.db
|
# access token as /mcp; see /.well-known/agent-card.json). Point it at your LM Studio instance:
|
||||||
|
# server address, no /v1 (start its server, and enable "Serve on Local Network" for a remote one).
|
||||||
|
# Unset LMSTUDIO_URL = both surfaces answer "not configured".
|
||||||
|
# LMSTUDIO_URL=http://192.168.1.20:1234
|
||||||
|
# Model id to ask for; empty = whichever model LM Studio has loaded.
|
||||||
|
# LMSTUDIO_MODEL=
|
||||||
|
# Only if the server requires an API key.
|
||||||
|
# LMSTUDIO_API_KEY=
|
||||||
|
# Seconds the LLM server may stay silent (before it starts answering, or between two pieces of the
|
||||||
|
# reply) before giving up (default 120).
|
||||||
|
# LMSTUDIO_TIMEOUT_SECONDS=120
|
||||||
|
# Every turn (chat or A2A) is summarised in the server's log. To also keep the model's full
|
||||||
|
# reasoning and replies, and the messages it was sent (for finding out why it loops), append a JSON
|
||||||
|
# trace to this file. It holds what users typed, so keep it private.
|
||||||
|
# CHAT_TRACE_FILE=data/chat-trace.jsonl
|
||||||
|
|
||||||
# --- WebMCP origin trial ------------------------------------------------------
|
# --- WebMCP origin trial ------------------------------------------------------
|
||||||
# Chrome's WebMCP (the UI's browser tools) is in an origin trial. Register BASE_URL's origin at
|
# Chrome's WebMCP (the UI's browser tools) is in an origin trial. Register BASE_URL's origin at
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ jobs:
|
|||||||
docker compose version
|
docker compose version
|
||||||
docker buildx version
|
docker buildx version
|
||||||
|
|
||||||
# Each suite runs once, under Cloverage; the merged report is the dockerized
|
# Each suite runs once, under a coverage tool. The Clojure reports (Cloverage) are merged
|
||||||
# equivalent of script/coverage.
|
# below; the UI's (c8) stands on its own. Together this is the dockerized equivalent of
|
||||||
|
# script/coverage.
|
||||||
|
- name: UI tests (coverage)
|
||||||
|
run: script/docker-ui-coverage
|
||||||
|
|
||||||
- name: Unit tests (coverage)
|
- name: Unit tests (coverage)
|
||||||
run: script/docker-unit-coverage
|
run: script/docker-unit-coverage
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,10 @@ node_modules
|
|||||||
# Compiled UI (script/ui-build)
|
# Compiled UI (script/ui-build)
|
||||||
resources/public/js
|
resources/public/js
|
||||||
.nrepl-port
|
.nrepl-port
|
||||||
# Local SQLite databases (TODO_DB_PATH, OAUTH_DB_PATH)
|
# clj-kondo / clojure-lsp caches
|
||||||
|
.clj-kondo
|
||||||
|
.lsp
|
||||||
|
# Local SQLite database (DB_PATH)
|
||||||
data
|
data
|
||||||
# Local secrets (copy .env-template to .env)
|
# Local secrets (copy .env-template to .env)
|
||||||
.env
|
.env
|
||||||
|
|||||||
+15
-1
@@ -2,7 +2,8 @@
|
|||||||
# to ../resources/public/js (see ui/shadow-cljs.edn), i.e. /build/resources/public/js here.
|
# to ../resources/public/js (see ui/shadow-cljs.edn), i.e. /build/resources/public/js here.
|
||||||
# trixie, not bookworm: its default-jre is Java 21, which shadow-cljs's Closure Compiler
|
# trixie, not bookworm: its default-jre is Java 21, which shadow-cljs's Closure Compiler
|
||||||
# requires (bookworm's is 17 -> UnsupportedClassVersionError).
|
# requires (bookworm's is 17 -> UnsupportedClassVersionError).
|
||||||
FROM node:22-trixie-slim AS ui
|
# ui-base holds what the UI's release build and its tests share: the toolchain and the deps.
|
||||||
|
FROM node:22-trixie-slim AS ui-base
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends default-jre-headless \
|
&& apt-get install -y --no-install-recommends default-jre-headless \
|
||||||
@@ -14,9 +15,22 @@ RUN npm ci
|
|||||||
# Resolve the ClojureScript deps in their own layer so source edits don't re-download them.
|
# Resolve the ClojureScript deps in their own layer so source edits don't re-download them.
|
||||||
COPY ui/shadow-cljs.edn ./
|
COPY ui/shadow-cljs.edn ./
|
||||||
RUN npx shadow-cljs classpath > /dev/null
|
RUN npx shadow-cljs classpath > /dev/null
|
||||||
|
|
||||||
|
FROM ui-base AS ui
|
||||||
|
COPY shared /build/shared
|
||||||
COPY ui/src ./src
|
COPY ui/src ./src
|
||||||
RUN npx shadow-cljs release app
|
RUN npx shadow-cljs release app
|
||||||
|
|
||||||
|
# The UI's tests, not part of the server image: `docker build --target ui-test`
|
||||||
|
# (script/docker-ui-test, script/docker-ui-coverage). Running the container runs the tests;
|
||||||
|
# `npm run coverage` (script/docker-ui-coverage) also writes the report to /build/target/coverage/ui.
|
||||||
|
FROM ui-base AS ui-test
|
||||||
|
COPY ui/.c8rc.json ./
|
||||||
|
COPY shared /build/shared
|
||||||
|
COPY ui/src ./src
|
||||||
|
COPY ui/test ./test
|
||||||
|
CMD ["npm", "test"]
|
||||||
|
|
||||||
# Stage 2: the server.
|
# Stage 2: the server.
|
||||||
# JDK 21+ is required by the MCP server (plumcp runs on virtual threads).
|
# JDK 21+ is required by the MCP server (plumcp runs on virtual threads).
|
||||||
FROM clojure:temurin-21-tools-deps
|
FROM clojure:temurin-21-tools-deps
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ dockerized integration tests, and joint line coverage.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Docker, for the integration tests and the `script/docker-*` tasks (all CI needs)
|
- Docker, for the integration tests and the `script/docker-*` tasks (all CI needs)
|
||||||
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
|
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (22.22+ for the UI tests; with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@@ -48,13 +48,16 @@ docker run --rm -p 3000:3000 tbd
|
|||||||
| `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) for MCP clients |
|
| `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) for MCP clients |
|
||||||
| `GET`, `POST` | `/oauth/authorize` | Where an MCP client sends the user's browser: Gitea login if needed, then a consent page |
|
| `GET`, `POST` | `/oauth/authorize` | Where an MCP client sends the user's browser: Gitea login if needed, then a consent page |
|
||||||
| `POST` | `/oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for tokens |
|
| `POST` | `/oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for tokens |
|
||||||
|
| `POST` | `/api/chat` | **Login required.** An [AG-UI](https://docs.ag-ui.com) run: body a `RunAgentInput` (`{"messages": [{"id": "m1", "role": "user", "content": "hi"}], ...}`, the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; optionally `"tools"`, the page's tools as AG-UI describes them: see [the chatbot's tools](#the-chatbots-tools), and `"context"`, ambient information for the run — the id of the UI page the user is on travels as `{"description": "page", "value": "tic-tac-toe"}`) sends it to the [chatbot's](#chatbot) agent and **streams the reply back** as AG-UI server-sent events (`text/event-stream`): `RUN_STARTED`, then `TEXT_MESSAGE_START`/`TEXT_MESSAGE_CONTENT`/`TEXT_MESSAGE_END` for the reply, `REASONING_MESSAGE_START`/`_CONTENT`/`_END` for the model's reasoning, `TOOL_CALL_START`/`_ARGS`/`_END` if it asks for tools to be called, and last `RUN_FINISHED` or `RUN_ERROR`. Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
|
||||||
|
| `POST` | `/a2a` | The [A2A server](#a2a-server): JSON-RPC 2.0, `message/send` only (no streaming; see below). **Needs an OAuth access token**, the same one an MCP client uses (`401` without one) |
|
||||||
|
| `GET` | `/.well-known/agent-card.json` | The A2A server's [AgentCard](https://a2a-protocol.org): its name, url, capabilities and skills |
|
||||||
| `GET` | `/api-docs` | Swagger UI |
|
| `GET` | `/api-docs` | Swagger UI |
|
||||||
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
|
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
|
||||||
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
|
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
|
||||||
| `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` |
|
| `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` |
|
||||||
| `POST` | `/auth/logout` | Ends the session |
|
| `POST` | `/auth/logout` | Ends the session |
|
||||||
| `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) |
|
| `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) |
|
||||||
| `GET` | `/api/todos` | **Login required.** Your todos: `{:todos [{:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}` |
|
| `GET` | `/api/todos` | **Login required.** Your todos: `{:todos [{:id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b" :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}` |
|
||||||
| `POST` | `/api/todos` | **Login required.** Body `{"title": "buy milk"}` creates a todo and returns it with `201` (400 if the title is blank or over 200 characters) |
|
| `POST` | `/api/todos` | **Login required.** Body `{"title": "buy milk"}` creates a todo and returns it with `201` (400 if the title is blank or over 200 characters) |
|
||||||
| `GET` | `/api/todos/:id` | **Login required.** One todo (404 if it doesn't exist or isn't yours) |
|
| `GET` | `/api/todos/:id` | **Login required.** One todo (404 if it doesn't exist or isn't yours) |
|
||||||
| `PATCH` | `/api/todos/:id` | **Login required.** Body with `title` and/or `done` (`{"done": true}`) updates the todo and returns it (400 for an empty or invalid change, 404 as above) |
|
| `PATCH` | `/api/todos/:id` | **Login required.** Body with `title` and/or `done` (`{"done": true}`) updates the todo and returns it (400 for an empty or invalid change, 404 as above) |
|
||||||
@@ -89,10 +92,16 @@ and the [MCP tools](#mcp-server) both call, so they behave the same and are test
|
|||||||
tool only translates its request into a service call and the result into a response, and never
|
tool only translates its request into a service call and the result into a response, and never
|
||||||
touches the database.
|
touches the database.
|
||||||
|
|
||||||
Todos are stored in a local [SQLite](https://www.sqlite.org/) file, `data/todo.db` by default
|
Everything the app stores (todos, and the MCP OAuth state) lives in one local
|
||||||
(git-ignored), which is created and migrated on the first todo request. Set `TODO_DB_PATH` to put it
|
[SQLite](https://www.sqlite.org/) file, `data/tbd.db` by default (git-ignored), which is created and
|
||||||
elsewhere. In Docker, mount a volume at that path (e.g. `docker run -v tbd-data:/app/data ...`), or the
|
migrated on first use. Each group owns its own tables (`tbd.todo.db`, `tbd.oauth.db`); `tbd.sqlite` opens the
|
||||||
todos are lost with the container.
|
file and runs their migrations. Set `DB_PATH` to put it elsewhere. In Docker, mount a volume at that path
|
||||||
|
(e.g. `docker run -v tbd-data:/app/data ...`), or the todos and MCP approvals are lost with the container.
|
||||||
|
|
||||||
|
Every id the app issues (a todo's, an OAuth grant's) is a UUID, never a number: a model that reads `7` as a
|
||||||
|
todo's id mixes it up with a 7 in a title. (`owner-id` is the user's id in Gitea, which it issues, so it stays
|
||||||
|
a number.) A database from before that is migrated when it is opened: rows keep their order and get UUIDs, so
|
||||||
|
the ids in URLs or notes from before no longer work.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# after logging in in a browser, copy the `tbd-session` cookie value:
|
# after logging in in a browser, copy the `tbd-session` cookie value:
|
||||||
@@ -158,12 +167,38 @@ The consent page is what stops a stranger's client (anyone can register one) fro
|
|||||||
sending you a link: nothing is issued without your click. Redirect URIs must be `https`, `http` on
|
sending you a link: nothing is issued without your click. Redirect URIs must be `https`, `http` on
|
||||||
`localhost`/`127.0.0.1`/`[::1]` (the port may vary), or an app's own scheme (`cursor://...`).
|
`localhost`/`127.0.0.1`/`[::1]` (the port may vary), or an app's own scheme (`cursor://...`).
|
||||||
|
|
||||||
What's stored is in `OAUTH_DB_PATH`: registered clients, and for each approval the user and a *hash* of the
|
What's stored is in the app's database (`DB_PATH`): registered clients, and for each approval the user and a *hash* of the
|
||||||
current access and refresh tokens. To cut a client off, delete its row from `grants`. Admin-ness isn't in
|
current access and refresh tokens. To cut a client off, delete its row from `grants`. Admin-ness isn't in
|
||||||
the token: it is looked up in `ADMIN_USERS` on every request, so removing someone from the list takes
|
the token: it is looked up in `ADMIN_USERS` on every request, so removing someone from the list takes
|
||||||
effect at once. A Gitea account that is disabled keeps working until its refresh token expires (up to 30
|
effect at once. A Gitea account that is disabled keeps working until its refresh token expires (up to 30
|
||||||
days without use) or its grant is deleted, since Gitea isn't asked again after the login.
|
days without use) or its grant is deleted, since Gitea isn't asked again after the login.
|
||||||
|
|
||||||
|
## A2A server
|
||||||
|
|
||||||
|
The server also speaks [A2A](https://a2a-protocol.org) (Agent2Agent) at `/a2a`, for other agents
|
||||||
|
(Claude Code, Codex, ...) to call directly, the way `/mcp` lets an MCP client call its tools. It's a
|
||||||
|
second face of the same agent as the chatbot (`tbd.agent.core`), but not tied to a browser page: an
|
||||||
|
A2A caller has no page open, so `tbd.agent.a2a` gives it the app's *server-side* tools instead —
|
||||||
|
todo CRUD and math, the same operations `tbd.mcp.tools` exposes to MCP clients, defined again over
|
||||||
|
`tbd.todo.service`/`tbd.math.core` directly (this ns doesn't depend on plumcp). Its system prompt
|
||||||
|
(`tbd.agent.instructions/a2a`) is framed for a caller with no page and no visual feedback, not the
|
||||||
|
chatbot's in-page persona.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://tbd.example.com/.well-known/agent-card.json # <BASE_URL>/a2a, its capabilities and skills
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","kind":"message","messageId":"m1","parts":[{"kind":"text","text":"list my todos"}]}}}' \
|
||||||
|
https://tbd.example.com/a2a
|
||||||
|
```
|
||||||
|
|
||||||
|
`$TOKEN` is the same OAuth access token an MCP client gets ([above](#connecting-an-mcp-client)):
|
||||||
|
`wrap-bearer-auth` verifies a token by looking it up, not by which `resource` it names, so a token
|
||||||
|
minted for `/mcp` works here too. Only `message/send` is implemented — the AgentCard says so
|
||||||
|
(`capabilities.streaming: false`) — so a call is synchronous: if the agent asks for a tool, this ns
|
||||||
|
runs it itself immediately (server-side, no browser to hand it to) and asks the agent again, up to 8
|
||||||
|
rounds, until it answers in text, which comes back as the JSON-RPC result (an A2A `Message`, role
|
||||||
|
`"agent"`). Streaming for a browser-driven conversation is what [AG-UI](#chatbot) is for.
|
||||||
|
|
||||||
## Authentication and the admin API
|
## Authentication and the admin API
|
||||||
|
|
||||||
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
|
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
|
||||||
@@ -179,11 +214,12 @@ login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
|
|||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
|
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
|
||||||
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP server: the OAuth issuer, and what MCP clients are told to use (`<BASE_URL>/mcp`) |
|
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP and A2A servers: the OAuth issuer, what MCP clients are told to use (`<BASE_URL>/mcp`), and the A2A AgentCard's `url` (`<BASE_URL>/a2a`) |
|
||||||
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
|
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
|
||||||
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
|
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
|
||||||
| `TODO_DB_PATH` | SQLite file for the todo group (default `data/todo.db`) |
|
| `DB_PATH` | The app's SQLite file: todos, and the MCP OAuth server's registered clients, codes and tokens (default `data/tbd.db`). Mount a volume for it in Docker, or todos are lost and every MCP client has to be approved again after each restart |
|
||||||
| `OAUTH_DB_PATH` | SQLite file for the MCP OAuth server: registered clients, codes and tokens (default `data/oauth.db`). Mount a volume for it in Docker too, or every MCP client has to be approved again after each restart |
|
| `LMSTUDIO_URL`, `LMSTUDIO_MODEL`, `LMSTUDIO_API_KEY`, `LMSTUDIO_TIMEOUT_SECONDS` | The LM Studio server behind the [chatbot](#chatbot). Only the URL is needed |
|
||||||
|
| `CHAT_TRACE_FILE` | Optional. A file to append a JSON trace of every chatbot turn to, [with the model's full reasoning and replies](#watching-the-chatbot-think). Off by default |
|
||||||
| `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) |
|
| `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) |
|
||||||
|
|
||||||
Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours
|
Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours
|
||||||
@@ -209,12 +245,20 @@ restarts), so switch it off when you're done.
|
|||||||
|
|
||||||
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
||||||
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
||||||
It's a small calculator form that calls `GET /api/add` and shows the sum (or the server's
|
It has four pages, as tabs across the top. Each has its own address in the URL's fragment (`/#/tic-tac-toe`,
|
||||||
validation error). Below it, once you're logged in, is a todo list (add, tick off, delete) backed by
|
`/#/calculator`, `/#/todos`, `/#/ai-setup`), so the server only ever serves `/`, and a page can be bookmarked or reloaded.
|
||||||
`/api/todos`; it stays hidden when you're not. At the bottom is a section with the
|
|
||||||
`claude mcp add ...` command to copy (with this page's own address in it) and the steps to sign in, for
|
| Tab | What's on it |
|
||||||
adding the [MCP server](#mcp-server) to Claude Code. A "Log in" / "Log out" link sits in the footer row
|
|-----|--------------|
|
||||||
next to the API docs links.
|
| Tic-tac-toe | Two players on one board, all client side |
|
||||||
|
| Calculator | A small form that calls `GET /api/add` and shows the sum (or the server's validation error) |
|
||||||
|
| Todos | Your todo list (add, tick off, delete), backed by `/api/todos`. The tab only shows once you're logged in; its address shows a "Log in" prompt otherwise |
|
||||||
|
| AI setup | The `claude mcp add ...` command to copy (with this page's own address in it) and the steps to sign in, for adding the [MCP server](#mcp-server) to Claude Code; and how to connect an MCP client to the page's own [browser tools](#browser-mcp-tools-webmcp) |
|
||||||
|
|
||||||
|
A "Log in" / "Log out" link sits in the footer row next to the API docs links.
|
||||||
|
|
||||||
|
A green square in the bottom left corner (on every page, opposite the blue WebMCP one) opens the
|
||||||
|
[chatbot](#chatbot).
|
||||||
|
|
||||||
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
|
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
|
||||||
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
|
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
|
||||||
@@ -224,30 +268,189 @@ directory at `/js`, and serves `resources/public/index.html`, the shell that mou
|
|||||||
| `script/ui-build` | Optimized release build into `resources/public/js` |
|
| `script/ui-build` | Optimized release build into `resources/public/js` |
|
||||||
| `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page |
|
| `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page |
|
||||||
|
|
||||||
The Docker image builds the UI in its own stage, so `docker build` needs no local Node.
|
The Docker image builds the UI in its own stage, so `docker build` needs no local Node. The UI's tests run
|
||||||
|
in another stage of the same Dockerfile (`--target ui-test`), which the server image doesn't include.
|
||||||
|
|
||||||
|
### Chatbot
|
||||||
|
|
||||||
|
The green square opens a small chat panel. What you type goes to `POST /api/chat`, an
|
||||||
|
[AG-UI](https://docs.ag-ui.com) run; the backend's agent (`tbd.agent.core`, a
|
||||||
|
[LangChain4j](https://docs.langchain4j.dev/) streaming chat model) forwards the conversation to an
|
||||||
|
[LM Studio](https://lmstudio.ai) server (its OpenAI-compatible `/v1/chat/completions`) and streams
|
||||||
|
the reply back as AG-UI events. The browser never talks to the LLM directly, so the server's address
|
||||||
|
(and key) stay on the backend. The panel shows "Thinking…" until the model's first word (a reasoning
|
||||||
|
model can take a while), then the reply appears as it is generated. What the model reasons is never
|
||||||
|
shown, only that it is thinking; the tool calls (see below) show as small grey lines between the
|
||||||
|
replies, in full, however long, and the conversation scrolls. The conversation lives in the page only
|
||||||
|
(a reload or logging out clears it), and the UI sends the last 40 messages of it. Chat needs a login,
|
||||||
|
like the todos: every message costs the LLM server's time.
|
||||||
|
|
||||||
|
The conversation scrolls. It follows the reply while you are at its end; scroll up to read back and it
|
||||||
|
stays put until you send a message or scroll down again. Drag the panel's top right corner to make it
|
||||||
|
wider or taller (the size is kept while the page is open, through Clear and closing the panel).
|
||||||
|
|
||||||
|
`curl -N` shows the stream (`-N` turns off curl's own buffering; use your session cookie):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"messages":[{"id":"m1","role":"user","content":"hi"}]}' http://localhost:3000/api/chat
|
||||||
|
```
|
||||||
|
|
||||||
|
#### The chatbot's tools
|
||||||
|
|
||||||
|
The chatbot can use the page. The [browser tools](#browser-mcp-tools-webmcp) (the calculator, the todos,
|
||||||
|
tic-tac-toe) are registered in the page's *model context*, `ui/src/tbd/ui/model_context.cljs`, which is
|
||||||
|
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded,
|
||||||
|
and this is exactly the shape of AG-UI's **frontend tools**: the agent asks, the browser runs them.
|
||||||
|
|
||||||
|
1. Each `POST /api/chat` carries `"tools"`, the ones the page currently offers, as AG-UI describes them
|
||||||
|
(`{"name", "description", "parameters"}`, the schema being JSON Schema), and `"context"` naming the
|
||||||
|
page (`{"description": "page", "value": "tic-tac-toe"}`). The backend converts them to LangChain4j's
|
||||||
|
typed tool specifications for the LLM, and stays stateless. Only the tools of the page being shown are
|
||||||
|
listed: a tool registered with `:pages #{:calculator}` is offered on the Calculator page and nowhere
|
||||||
|
else (tic-tac-toe's tools are the Tic-tac-toe page's, the todos' are the Todos page's), and one with no
|
||||||
|
`:pages` (`ping`, `whoami`, `navigate`) on every page. The list follows the page the user is on each
|
||||||
|
time the conversation is sent (so does the page's name, which the backend's agent puts in the system
|
||||||
|
message ahead of the conversation, with the names of the tools it has), and a call for a tool the page
|
||||||
|
doesn't offer is refused with an error the LLM reads. The LLM can move between pages with `navigate`;
|
||||||
|
its next request then carries the new page and that page's tools. An agent outside the page, through
|
||||||
|
WebMCP, is still offered all of them.
|
||||||
|
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with
|
||||||
|
`TOOL_CALL_START`/`TOOL_CALL_ARGS`/`TOOL_CALL_END` events, one triple per call (`TOOL_CALL_ARGS`'s
|
||||||
|
`delta` being the JSON text the model wrote). The backend only relays them.
|
||||||
|
3. The page intercepts them and calls the tools right there (one after the other), through the same
|
||||||
|
handlers WebMCP uses, so they act on the page as a user would and you see it happen. The chat panel
|
||||||
|
shows each call and its result in small print between the messages.
|
||||||
|
4. The results go back as messages with the role `tool` (`{"role": "tool", "toolCallId": "call_1",
|
||||||
|
"content": "..."}`, after the assistant message that asked, which carries `"toolCalls"`), in
|
||||||
|
another request with the conversation so far, for the LLM to go on from, until it answers with words.
|
||||||
|
|
||||||
|
A call that can't be made (a tool that isn't there, arguments that aren't a JSON object, a tool that
|
||||||
|
fails) is a result with the reason, for the model to read and try again from. To keep a model that never
|
||||||
|
stops calling tools from acting on your page for ever, one message of yours gets it at most 12 calls;
|
||||||
|
then the chat stops, and says so. Results are cut to the 8000 characters the server accepts.
|
||||||
|
|
||||||
|
The chatbot is told to be an agent, not a conversationalist: its system prompt
|
||||||
|
(`tbd.agent.instructions/chat`, in `src/tbd/agent/instructions.clj` — a JVM-only namespace, never on the
|
||||||
|
UI's classpath, so the chatbot's own instructions are never bundled into the browser) says that what it
|
||||||
|
does shows on your screen at once, so it should call a tool in the same turn rather than offer to, chain
|
||||||
|
calls (navigate, list, then act), take obvious defaults instead of asking, and answer in one short
|
||||||
|
sentence, since the call and its result are already shown. It reuses the wording that genuinely is shared
|
||||||
|
with an outside agent (`act`, `work-it-out`, `tic-tac-toe-rules`, `examples`) from `tbd.agent.prompt` (the
|
||||||
|
shared `.cljc`, [see below](#browser-mcp-tools-webmcp)) rather than duplicating it. If a model still
|
||||||
|
answers requests with offers, that's what to tune for it; the tool descriptions in `webmcp.cljs` are the
|
||||||
|
other lever.
|
||||||
|
|
||||||
|
To try it, use a model that supports tool use in LM Studio, log in, and ask the chat something the page
|
||||||
|
can do: *"add 2 and 3 with the calculator"*, *"add a todo to buy milk"*, *"play tic-tac-toe with me, you're O"*.
|
||||||
|
|
||||||
|
A reverse proxy in front of tbd must not buffer responses, or the reply arrives all at once at the end
|
||||||
|
(the server sends `X-Accel-Buffering: no`, which nginx obeys; for others, turn buffering off for `/api/chat`).
|
||||||
|
The `REASONING_MESSAGE_CONTENT` events also keep the connection from looking idle to a proxy while the model reasons; the UI drops their text, only using them to show "Thinking…".
|
||||||
|
Anything that goes wrong is logged on the server's stdout, with a `[tbd] chat:` prefix.
|
||||||
|
|
||||||
|
Point the backend at your instance with environment variables (see [.env-template](.env-template)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LMSTUDIO_URL=http://192.168.1.20:1234 script/server
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `LMSTUDIO_URL` is required (the address of LM Studio's server, without `/v1`; for a remote instance
|
||||||
|
turn on "Serve on Local Network" there). `LMSTUDIO_MODEL` picks a model by id (default: the one LM Studio
|
||||||
|
has loaded), `LMSTUDIO_API_KEY` is sent as a bearer token if the server wants one, and
|
||||||
|
`LMSTUDIO_TIMEOUT_SECONDS` (default 120) is how long the LLM server may stay silent, before it starts
|
||||||
|
answering or between two pieces of it, before the server gives up. Without `LMSTUDIO_URL` the server
|
||||||
|
still starts (it says so), and the chat panel shows "chat is not configured".
|
||||||
|
|
||||||
|
#### Watching the chatbot think
|
||||||
|
|
||||||
|
Every request to `/api/chat` is one turn of the model, and every turn is logged on stdout: one
|
||||||
|
`turn-start` line when it goes out and one `turn-end` line when it ends. The tool loop runs in the browser,
|
||||||
|
so the server sees each tool result as a fresh request; the turns of one loop are tied together by a `run`
|
||||||
|
id (a hash of the user's last message), with `step` counting them from 0.
|
||||||
|
|
||||||
|
```
|
||||||
|
[tbd] chat turn-start req=9c1e4a07 run=d4a6cdb3 step=3 page=tic-tac-toe model=qwen messages=8 tools=6 prompt-chars=1180 run-calls=3
|
||||||
|
[tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-call tool=get_tic_tac_toe count=3 args={}
|
||||||
|
[tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-thinking count=8 text="Let me check the board again."
|
||||||
|
[tbd] chat turn-end req=9c1e4a07 run=d4a6cdb3 step=3 outcome=tool-calls duration-ms=18040 ttft-ms=2210 think-ms=15100 events=1830 thinking-chars=9210 content-chars=0 calls="get_tic_tac_toe{}" flags=repeated-thinking
|
||||||
|
```
|
||||||
|
|
||||||
|
`turn-end` says how it ended (`outcome`: `done`, `tool-calls`, `error`, `upstream-error`, or `aborted` when the
|
||||||
|
browser went away), how long it took (`ttft-ms` is the wait for the first piece, which includes the LLM
|
||||||
|
reading the prompt; `think-ms` the time spent reasoning before answering), how much it wrote (`events` is
|
||||||
|
close to a token count), and `finish-reason` and token counts when the LLM server sends them. `flags` is
|
||||||
|
the grep handle for a stuck model, with a `loop-suspect` line for each trip as it happens:
|
||||||
|
|
||||||
|
| Flag | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| `repeated-call` | The same tool was called with the same arguments 3 or more times in one run (checked when the turn starts) |
|
||||||
|
| `repeated-thinking` | The reasoning said the same sentence again, ignoring case and punctuation: at the 4th time, then the 8th, 16th, ... |
|
||||||
|
| `long-reasoning` | The reasoning passed 8000 characters, then 16000, ... (only the ones without an answer yet are worrying) |
|
||||||
|
| `truncated` | The LLM stopped with `finish_reason: length`: it ran out of tokens, typically in a reasoning loop |
|
||||||
|
|
||||||
|
These are heuristics for where to look, not verdicts, and nothing is stopped: the server's timeout only
|
||||||
|
counts *silence*, so a model that reasons forever, out loud, is never cut off by it.
|
||||||
|
|
||||||
|
The log has no text of the conversation. To read what the model actually thought, set `CHAT_TRACE_FILE`
|
||||||
|
(say `data/chat-trace.jsonl`, which git ignores): each turn's entries are appended there as JSON lines, with
|
||||||
|
the full `reasoning`, `reply` and (in `turn-start`) the `input` messages of the run, the tool results the
|
||||||
|
model was reacting to. It holds what the user typed, so treat it like the database.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# the runs that got stuck, and how
|
||||||
|
jq -c 'select((.flags // []) | length > 0) | {run, step, flags, thinking_chars: ."thinking-chars"}' data/chat-trace.jsonl
|
||||||
|
# what the model said in one turn
|
||||||
|
jq -r 'select(.req == "9c1e4a07" and .event == "turn-end") | .reasoning' data/chat-trace.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
### Browser MCP tools (WebMCP)
|
### Browser MCP tools (WebMCP)
|
||||||
|
|
||||||
The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev):
|
The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev):
|
||||||
a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the
|
a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the
|
||||||
[server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing
|
[server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing
|
||||||
what you could do with the mouse and keyboard, so you see it happen. They are in `ui/src/tbd/ui/webmcp.cljs`,
|
what you could do with the mouse and keyboard, so you see it happen. A tool that changes something opens the tab it acts on first (the todo tools open Todos, the game tools open Tic-tac-toe, the calculator tools open Calculator). They are in `ui/src/tbd/ui/webmcp.cljs`,
|
||||||
registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package):
|
registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package):
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `ping`, `whoami` | Health check; who is logged in on the page, if anyone |
|
| `ping`, `whoami` | Health check; who is logged in on the page, if anyone |
|
||||||
|
| `navigate` | Opens a page (`tic-tac-toe`, `calculator`, `todos` or `ai-setup`) as a click on its tab would; `todos` needs a login, like its tab. The chatbot is then offered the new page's tools |
|
||||||
| `get_calculator` | What the calculator form holds: its two fields and the result it shows |
|
| `get_calculator` | What the calculator form holds: its two fields and the result it shows |
|
||||||
| `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows |
|
| `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows |
|
||||||
| `list_todos`, `get_todo` | The todos the page shows (login required, like the todo list itself) |
|
| `list_todos` | The todos the page shows (login required, like the todo list itself) |
|
||||||
| `create_todo` | Types a `title` into the new-todo field, presses Add, returns the new todo |
|
| `get_todo` | Reads todos by id (`todos`: a list of `{id}`) |
|
||||||
| `set_todo_done` | Ticks or unticks a todo's checkbox (`id`, `done`) |
|
| `create_todo` | Types each title into the new-todo field and presses Add (`todos`: a list of `{title}`), returns the new todos |
|
||||||
| `delete_todo` | Presses a todo's Delete button |
|
| `set_todo_done` | Ticks or unticks the checkboxes of several todos in one call (`todos`: a list of `{id, done}`), so a bulk change is one tool call |
|
||||||
|
| `delete_todo` | Presses the Delete button of several todos (`todos`: a list of `{id}`) |
|
||||||
|
|
||||||
|
The todo tools take their work as a list, so a batch ("untick all of them") is one tool call: the chatbot
|
||||||
|
is capped in the tool calls it may make in a turn. A batch is checked in full before its first change is made,
|
||||||
|
and then made one todo at a time; if one fails part way, the error says how many were done.
|
||||||
|
|
||||||
Each tool dispatches the same re-frame events the views do and waits for the result to show up in
|
Each tool dispatches the same re-frame events the views do and waits for the result to show up in
|
||||||
the app-db, so what the agent sees is what the page shows, server errors included. There is no
|
the app-db, so what the agent sees is what the page shows, server errors included. There is no
|
||||||
`update_todo` with a title as on the server, because the page can't edit one.
|
`update_todo` with a title as on the server, because the page can't edit one.
|
||||||
|
|
||||||
|
Next to the tools the page registers **prompts** and **resources** (`prompts` and `resources` in `webmcp.cljs`),
|
||||||
|
so a client's lists of them are not empty. They carry the same prompt engineering as the chatbot, from
|
||||||
|
`shared/tbd/agent/prompt.cljc` (a `.cljc`, on the classpath of both the server and the UI build): act instead
|
||||||
|
of offering, chain the calls, take obvious defaults, report in a sentence, and play tic-tac-toe by the turn.
|
||||||
|
The WebMCP bridge sends no server-level instructions, so this is how Claude or Codex learns them:
|
||||||
|
|
||||||
|
| Prompt | What it is |
|
||||||
|
|--------|-----------|
|
||||||
|
| `drive_the_app` | The instructions, then the `task` you gave it (Claude Code lists it as a slash command). Without a task, the agent asks what you want |
|
||||||
|
| `play_tic_tac_toe` | Plays a game with you, one move on its own turn, as `side` (`x` or `o`, default `o`) |
|
||||||
|
|
||||||
|
| Resource | What it is |
|
||||||
|
|----------|-----------|
|
||||||
|
| `webmcp://app/guide` | The instructions, as Markdown: read this first |
|
||||||
|
| `webmcp://app/state` | JSON: the page that is open, the pages (which need a login) and who is logged in |
|
||||||
|
|
||||||
|
The bridge prefixes their names with the page's channel, like the tools'. A client does not read a
|
||||||
|
resource on its own: mention it (`@`) or ask the agent to read the guide.
|
||||||
|
|
||||||
To connect (the page's "Browser tools (WebMCP)" section says the same):
|
To connect (the page's "Browser tools (WebMCP)" section says the same):
|
||||||
|
|
||||||
1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude`
|
1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude`
|
||||||
@@ -271,23 +474,35 @@ script/repl
|
|||||||
| Task | What it does |
|
| Task | What it does |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `script/unit-test` | Runs the Clojure tests in `test/` |
|
| `script/unit-test` | Runs the Clojure tests in `test/` |
|
||||||
| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node (needs Node and a JDK; CI doesn't run it yet) |
|
| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node, in a [jsdom](https://github.com/jsdom/jsdom) window (needs Node and a JDK) |
|
||||||
| `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down |
|
| `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down |
|
||||||
| `script/test` | Unit tests, then integration tests (stops at the first failure) |
|
| `script/test` | UI tests, unit tests, then integration tests (stops at the first failure) |
|
||||||
| `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) |
|
| `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) |
|
||||||
|
| `script/docker-ui-test` | Same as `script/ui-test`, but inside a Node + JDK image (Docker only) |
|
||||||
|
|
||||||
|
The UI tests render the real app in jsdom against a fake server and drive it like a user (type, submit,
|
||||||
|
click), next to plain tests of the re-frame events and subscriptions, the game rules and the WebMCP tools
|
||||||
|
(`ui/test/tbd/ui/`; the shared fakes are in `test_util.cljs`). `ui/test/dom-setup.cjs` gives them a
|
||||||
|
`window` and the real WebMCP library, and fails a run that hangs, rejects a promise nobody handles, or ends
|
||||||
|
without a result (a test that never calls `done` would otherwise pass or stall silently). `npm test` in
|
||||||
|
`ui/` preloads it, so use that (or the scripts) rather than running the build's output directly.
|
||||||
|
|
||||||
### Coverage
|
### Coverage
|
||||||
|
|
||||||
Line coverage via [Cloverage](https://github.com/cloverage/cloverage):
|
Line coverage of the Clojure code via [Cloverage](https://github.com/cloverage/cloverage), and of the UI via
|
||||||
|
[c8](https://github.com/bcoe/c8):
|
||||||
|
|
||||||
| Task | Output |
|
| Task | Output |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `script/unit-coverage` | `target/coverage/unit/` |
|
| `script/unit-coverage` | `target/coverage/unit/` |
|
||||||
| `script/integration-coverage` | `target/coverage/integration/` |
|
| `script/integration-coverage` | `target/coverage/integration/` |
|
||||||
| `script/coverage` | Runs both, then writes the merged report to `target/coverage/merged/` |
|
| `script/ui-coverage` | `target/coverage/ui/` (V8 coverage of the compiled UI, mapped back to `ui/src/**/*.cljs` through the source maps) |
|
||||||
| `script/docker-unit-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run and the merge step, so `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure or Babashka (this is what CI runs) |
|
| `script/coverage` | Runs all three, then writes the merged Clojure report to `target/coverage/merged/` (the UI's stays separate) |
|
||||||
|
| `script/docker-unit-coverage`, `script/docker-ui-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run, the UI run and the merge step, so `script/docker-ui-coverage`, `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure, Node or Babashka (this is what CI runs) |
|
||||||
|
|
||||||
Each report directory has an `index.html` and an `lcov.info`. See
|
Each report directory has an `index.html` and an `lcov.info`. The UI's numbers are approximate where one
|
||||||
|
ClojureScript form compiles to several JavaScript blocks (a `case` or `cond`, say): a line can show as
|
||||||
|
uncovered when only part of the form ran. See
|
||||||
[integration-test/README.md](integration-test/README.md) for how integration coverage is
|
[integration-test/README.md](integration-test/README.md) for how integration coverage is
|
||||||
collected.
|
collected.
|
||||||
|
|
||||||
@@ -302,19 +517,23 @@ src/tbd/
|
|||||||
<group>/schema.clj Malli schemas of the group's input, shared by its routes and the MCP tools
|
<group>/schema.clj Malli schemas of the group's input, shared by its routes and the MCP tools
|
||||||
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
|
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
|
||||||
mcp/ MCP server (PluMCP) mounted at `/mcp`: tools (tools.clj), Malli-to-tool glue (schema.clj) and the Ring handler (routes.clj)
|
mcp/ MCP server (PluMCP) mounted at `/mcp`: tools (tools.clj), Malli-to-tool glue (schema.clj) and the Ring handler (routes.clj)
|
||||||
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
|
oauth/ tbd as the OAuth authorization server for MCP and A2A clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` and `/a2a` (middleware)
|
||||||
|
agent/ The agent: a LangChain4j streaming chat model (core), JSON-Schema translation for tool specs (json_schema), its system prompts (instructions), and the A2A server mounted at `/a2a` (a2a)
|
||||||
|
chat/ /api/chat: the AG-UI wire protocol (routes), the request's Malli schema (schema), per-turn logging and loop detection (trace) — the agent itself is agent/core.clj
|
||||||
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
|
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
|
||||||
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
|
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
|
||||||
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,core}.cljs, tests in ui/test/
|
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an outside agent, through WebMCP, is given — the chatbot's own system prompt is server-only, in src/tbd/agent/instructions.clj)
|
||||||
|
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,model_context,core}.cljs, tests in ui/test/
|
||||||
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
|
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
|
||||||
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
||||||
test/ Unit tests
|
test/ Unit tests
|
||||||
integration-test/ Dockerized integration tests and coverage harness
|
integration-test/ Dockerized integration tests and coverage harness
|
||||||
script/ Bash entry points (server, ui-build, test, coverage, repl, ...) and coverage helpers
|
script/ Bash entry points (server, ui-build, ui-test, test, coverage, repl, ...) and coverage helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route
|
To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route
|
||||||
vector, then add it to the router in `src/tbd/core.clj`. To make a route admin-only, put
|
vector, then add it to the router in `src/tbd/core.clj`. To make a route admin-only, put
|
||||||
`:admin? true` in its route data (admin allowlist), or `:login? true` (any logged-in user; the user is
|
`:admin? true` in its route data (admin allowlist), or `:login? true` (any logged-in user; the user is
|
||||||
passed to the handler as `:user`). A group with storage keeps it in its own `tbd.<group>.db`, apart
|
passed to the handler as `:user`). A group with storage keeps it in its own `tbd.<group>.db`, apart
|
||||||
from its business logic in `core.clj`.
|
from its business logic in `core.clj`, with a `migrate!` fn for its tables in the app's one database;
|
||||||
|
add it to the `sqlite/open` call in `make-router`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{:paths ["src" "resources" "integration-test"]
|
{:paths ["src" "shared" "resources" "integration-test"]
|
||||||
:deps
|
:deps
|
||||||
{org.clojure/clojure {:mvn/version "1.12.0"}
|
{org.clojure/clojure {:mvn/version "1.12.0"}
|
||||||
ring/ring-jetty-adapter {:mvn/version "1.12.2"}
|
ring/ring-jetty-adapter {:mvn/version "1.12.2"}
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
;; The MCP server, mounted at /mcp (see tbd.mcp.routes). Needs JDK 21+ (virtual threads).
|
;; The MCP server, mounted at /mcp (see tbd.mcp.routes). Needs JDK 21+ (virtual threads).
|
||||||
;; plumcp needs exactly one JSON module; jsonista is already on the classpath via muuntaja.
|
;; plumcp needs exactly one JSON module; jsonista is already on the classpath via muuntaja.
|
||||||
io.github.plumce/plumcp.core {:mvn/version "0.3.0-beta2"}
|
io.github.plumce/plumcp.core {:mvn/version "0.3.0-beta2"}
|
||||||
io.github.plumce/plumcp.core-json-jsonista {:mvn/version "0.3.0-beta2"}}
|
io.github.plumce/plumcp.core-json-jsonista {:mvn/version "0.3.0-beta2"}
|
||||||
|
;; The agent (see tbd.agent.core): a LangChain4j streaming chat model talking to LM Studio's
|
||||||
|
;; OpenAI-compatible server. Plain libraries, no Spring/Quarkus runtime pulled in.
|
||||||
|
dev.langchain4j/langchain4j {:mvn/version "1.20.0"}
|
||||||
|
dev.langchain4j/langchain4j-open-ai {:mvn/version "1.20.0"}}
|
||||||
|
|
||||||
:aliases
|
:aliases
|
||||||
{:dev
|
{:dev
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
(println "Test 15: the todo API without a session returns 401")
|
(println "Test 15: the todo API without a session returns 401")
|
||||||
(assert-eq "GET list" 401 (:status (get* (str base-url "/api/todos"))))
|
(assert-eq "GET list" 401 (:status (get* (str base-url "/api/todos"))))
|
||||||
(assert-eq "POST create" 401 (:status (post-json* (str base-url "/api/todos") "{\"title\":\"x\"}")))
|
(assert-eq "POST create" 401 (:status (post-json* (str base-url "/api/todos") "{\"title\":\"x\"}")))
|
||||||
(assert-eq "GET one" 401 (:status (get* (str base-url "/api/todos/1"))))
|
(assert-eq "GET one" 401 (:status (get* (str base-url "/api/todos/3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b"))))
|
||||||
(println)
|
(println)
|
||||||
|
|
||||||
(println "Test 16: POST /mcp without a token returns 401 pointing at the OAuth metadata")
|
(println "Test 16: POST /mcp without a token returns 401 pointing at the OAuth metadata")
|
||||||
|
|||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the full suite (unit + integration) with line coverage and write the joint
|
# Run the full suite (UI + unit + integration) with line coverage. The Clojure suites'
|
||||||
# report to target/coverage/merged.
|
# joint report goes to target/coverage/merged; the UI's is separate (target/coverage/ui).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
script/ui-coverage
|
||||||
script/unit-coverage
|
script/unit-coverage
|
||||||
script/integration-coverage
|
script/integration-coverage
|
||||||
script/coverage-report
|
script/coverage-report
|
||||||
|
|||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the UI's tests under c8 inside a Node + JDK image; needs only Docker. The report is
|
||||||
|
# copied out with `docker cp` (not a bind mount) so it works when this itself runs in a
|
||||||
|
# job container. Output: target/coverage/ui.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
source script/lib/docker.sh
|
||||||
|
|
||||||
|
build_ui_test_image
|
||||||
|
|
||||||
|
id=$(docker create --security-opt apparmor=unconfined tbd-ui-test npm run coverage)
|
||||||
|
trap 'docker rm -f "$id" >/dev/null' EXIT
|
||||||
|
|
||||||
|
status=0
|
||||||
|
docker start -a "$id" || status=$?
|
||||||
|
|
||||||
|
rm -rf target/coverage/ui
|
||||||
|
mkdir -p target/coverage
|
||||||
|
docker cp "$id:/build/target/coverage/ui" target/coverage/ || echo "[test] WARNING: no UI coverage report was produced"
|
||||||
|
exit "$status"
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the UI's (ClojureScript) tests inside a Node + JDK image; needs only Docker.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
source script/lib/docker.sh
|
||||||
|
|
||||||
|
build_ui_test_image
|
||||||
|
exec docker run --rm --security-opt apparmor=unconfined tbd-ui-test
|
||||||
@@ -18,3 +18,9 @@ build_unit_image() {
|
|||||||
use_builder
|
use_builder
|
||||||
docker buildx build --load --build-arg DEPS_ALIASES=test:cov -t tbd-unit .
|
docker buildx build --load --build-arg DEPS_ALIASES=test:cov -t tbd-unit .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Builds the UI's test image (Node + JDK, the UI's deps, sources and tests), tagged tbd-ui-test.
|
||||||
|
build_ui_test_image() {
|
||||||
|
use_builder
|
||||||
|
docker buildx build --load --target ui-test -t tbd-ui-test .
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the unit tests, then the integration tests (stops at the first failure).
|
# Run the UI tests, the unit tests, then the integration tests (stops at the first failure).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
script/ui-test
|
||||||
script/unit-test
|
script/unit-test
|
||||||
script/integration-test
|
script/integration-test
|
||||||
|
|||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the UI's tests with line coverage (c8, mapped back to the ClojureScript); the report goes
|
||||||
|
# to target/coverage/ui. Needs Node and a JDK, like script/ui-test.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/../ui"
|
||||||
|
|
||||||
|
[[ -d node_modules ]] || npm ci
|
||||||
|
exec npm run coverage
|
||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the UI's (ClojureScript) tests under Node. Needs Node and a JDK, like script/ui-build.
|
# Run the UI's (ClojureScript) tests under Node, in a jsdom window. Needs Node and a JDK, like
|
||||||
|
# script/ui-build. script/docker-ui-test does the same with only Docker.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/../ui"
|
cd "$(dirname "$0")/../ui"
|
||||||
|
|
||||||
[[ -d node_modules ]] || npm ci
|
[[ -d node_modules ]] || npm ci
|
||||||
exec npx shadow-cljs compile test
|
exec npm test
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
(ns tbd.agent.prompt
|
||||||
|
"What an LLM is told about driving this app through the page's tools (tbd.ui.webmcp), for an
|
||||||
|
MCP client outside the page (Claude, Codex) connected through WebMCP: it gets this as the
|
||||||
|
prompts and resources the page registers (tbd.ui.webmcp, in the browser: `guide`, `task`,
|
||||||
|
`tic-tac-toe`). This is a .cljc (compiled into the browser bundle, see ui/shadow-cljs.edn's
|
||||||
|
`:source-paths`) because those are genuinely needed client-side.
|
||||||
|
|
||||||
|
The page's own chatbot's system prompt is a separate, server-only concern: it lives in
|
||||||
|
tbd.agent.instructions (`src/`, never on the UI's classpath), which reuses `act`,
|
||||||
|
`work-it-out`, `tic-tac-toe-rules` and `examples` from here rather than duplicating them, so the
|
||||||
|
wording stays shared without the chatbot's own instructions being bundled into the browser.
|
||||||
|
|
||||||
|
It is about acting. A model that is only asked to be helpful answers a request for action with
|
||||||
|
an offer of help, so the text says: call the tool in the same turn, chain the calls, say little
|
||||||
|
afterwards, and the few cases where not to. It is short and concrete on purpose, with examples:
|
||||||
|
the models behind LM Studio follow that better than a long list of rules."
|
||||||
|
(:require [clojure.string :as str]))
|
||||||
|
|
||||||
|
(def act
|
||||||
|
"Shared with tbd.agent.instructions/chat, the in-page chatbot's own system prompt."
|
||||||
|
(str "Act, don't offer. When the user asks for something a tool can do, call the tool in this same\n"
|
||||||
|
"turn. Never say you are about to do something, never ask \"would you like me to\", never ask them\n"
|
||||||
|
"to confirm what they just asked for.\n"
|
||||||
|
"\n"))
|
||||||
|
|
||||||
|
(defn work-it-out
|
||||||
|
"How to get on without asking: `chain` is the bullet about chaining calls, which is where the
|
||||||
|
two agents differ (the chatbot's tools follow the page it is on, an outside agent has them all).
|
||||||
|
Shared with tbd.agent.instructions/chat."
|
||||||
|
[chain]
|
||||||
|
(str "Work it out yourself:\n"
|
||||||
|
"- Detail missing: take the obvious default and go. Ask only when a required argument is genuinely\n"
|
||||||
|
" unknowable (which of several todos they mean) or when you would delete or overwrite something\n"
|
||||||
|
" and cannot tell what.\n"
|
||||||
|
chain
|
||||||
|
"- The page changes between messages: the user clicks and types too. A tool result is only what the\n"
|
||||||
|
" page held when it ran, and results from an earlier message are marked out of date. Before you\n"
|
||||||
|
" answer from the page or act on it (a board, a list, an id), read it again with a tool. When the\n"
|
||||||
|
" user says it changed or that you are wrong, read it again first: never go by an earlier result\n"
|
||||||
|
" or by what you said before.\n"
|
||||||
|
"- A call fails: read the error, fix the arguments or read the state, and try once more. No\n"
|
||||||
|
" apologies, no explanation of what went wrong unless it still fails.\n"
|
||||||
|
"\n"))
|
||||||
|
|
||||||
|
(def tic-tac-toe-rules
|
||||||
|
"Shared with tbd.agent.instructions/chat."
|
||||||
|
(str "Tic-tac-toe: check whose turn it is, move only for your own side, one move, then stop and let the\n"
|
||||||
|
"person play theirs.\n"
|
||||||
|
"\n"))
|
||||||
|
|
||||||
|
(def examples
|
||||||
|
"Shared with tbd.agent.instructions/chat."
|
||||||
|
(str "Examples:\n"
|
||||||
|
"\"add a todo to buy milk\" -> create_todo{\"todos\":[{\"title\":\"buy milk\"}]} -> \"Added.\"\n"
|
||||||
|
"\"tick off the milk one\" -> list_todos -> set_todo_done{\"todos\":[{\"id\":\"3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b\",\"done\":true}]} -> \"Ticked off.\"\n"
|
||||||
|
"\"untick all my todos\" -> list_todos -> ONE set_todo_done with every todo in \"todos\" -> \"Unticked them all.\"\n"
|
||||||
|
"\"add milk and eggs\" -> ONE create_todo with both titles in \"todos\" -> \"Added both.\"\n"
|
||||||
|
"\"what's 19 and 4?\" -> add{\"x\":19,\"y\":4} -> \"23.\"\n"
|
||||||
|
"\"play tic tac toe, you're O\" -> get_tic_tac_toe -> it is X's turn -> \"You're up, X starts.\""))
|
||||||
|
|
||||||
|
;; --- An MCP client outside the page (Claude, Codex) ---
|
||||||
|
|
||||||
|
(def guide
|
||||||
|
"The instructions of an agent that drives the page from outside, through WebMCP: the resource
|
||||||
|
the page registers for it to read first, and what a prompt starts from. The WebMCP bridge sends
|
||||||
|
no `instructions` of its own, so this is where the agent learns that the tools are the page's
|
||||||
|
and what the user expects of them. Markdown, since a client shows it as a document."
|
||||||
|
(str "# Driving this web app\n"
|
||||||
|
"\n"
|
||||||
|
"You are connected to a web app the user has open in their browser. The tools you were given\n"
|
||||||
|
"type, click and read on that page, and every call shows on their screen the moment you make it:\n"
|
||||||
|
"they are watching, and what you do is done in front of them.\n"
|
||||||
|
"\n"
|
||||||
|
"The app has several pages, which the `navigate` tool lists. Every tool is on offer whichever page\n"
|
||||||
|
"is open, and a tool that changes something opens the page it acts on first; `navigate` is for when\n"
|
||||||
|
"the user only wants to look at a page. The `webmcp://app/state` resource says which page is open\n"
|
||||||
|
"and whether anyone is logged in.\n"
|
||||||
|
"\n"
|
||||||
|
act
|
||||||
|
(work-it-out (str "- Chain calls without stopping. You need an id: list first, then act. Never invent an id, a\n"
|
||||||
|
" title or the state of the page: read it with a tool.\n"))
|
||||||
|
"Then report in one short plain sentence, from the tool results: \"Added it.\" \"That's 23.\" The user\n"
|
||||||
|
"sees the page change, so don't describe what it now shows and don't paste JSON. Say something\n"
|
||||||
|
"was done only if a tool result says it was. Not being logged in is the user's to fix, on the page:\n"
|
||||||
|
"tell them so.\n"
|
||||||
|
"\n"
|
||||||
|
tic-tac-toe-rules
|
||||||
|
examples))
|
||||||
|
|
||||||
|
(defn task
|
||||||
|
"The prompt that hands an outside agent a job on the page: `guide`, then the user's `request` if
|
||||||
|
they gave one (else the agent asks what they want)."
|
||||||
|
[request]
|
||||||
|
(str guide
|
||||||
|
"\n\n"
|
||||||
|
(if (and (string? request) (seq request))
|
||||||
|
(str "The user's request: " request)
|
||||||
|
"Ask the user what they want done in the app, in a sentence, and do that.")))
|
||||||
|
|
||||||
|
(defn tic-tac-toe
|
||||||
|
"The prompt of a game of tic-tac-toe with the person at the page, for an outside agent playing
|
||||||
|
`side`: \"x\" or \"o\" (anything else is \"o\": X goes first, so the person, who has the board in
|
||||||
|
front of them, starts)."
|
||||||
|
[side]
|
||||||
|
(let [side (if (contains? #{"x" "o"} side) side "o")]
|
||||||
|
(str "You are playing tic-tac-toe on the app's page against the person who has it open, as "
|
||||||
|
(str/upper-case side) ". The tools are get_tic_tac_toe, move_tic_tac_toe and new_tic_tac_toe_game.\n"
|
||||||
|
"\n"
|
||||||
|
"Read the game with get_tic_tac_toe. If it is your side's turn, make one move with\n"
|
||||||
|
"move_tic_tac_toe and stop: never move for the other side, that is the person's, made on the\n"
|
||||||
|
"page. If it is not your side's turn, say so in a few words and wait for the person to tell\n"
|
||||||
|
"you they have moved; don't poll. Then read the game again and go on. When it is over, say\n"
|
||||||
|
"who won, or that it is a draw, and don't start another game unless asked.")))
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
(ns tbd.agent.a2a
|
||||||
|
"The A2A (Agent2Agent, https://a2a-protocol.org) server, mounted at `/a2a`, with its discovery
|
||||||
|
document at `/.well-known/agent-card.json`. Unlike /api/chat (the in-page chatbot, driven by the
|
||||||
|
browser's own tools) an A2A caller has no page open, so this agent's tools are the app's
|
||||||
|
server-side ones — the same operations tbd.mcp.tools exposes to MCP clients, defined again here
|
||||||
|
directly over tbd.todo.service/tbd.math.core (not through plumcp's tool-item wrapper, which
|
||||||
|
tbd.mcp.tools builds for a different transport) so this ns has no plumcp dependency.
|
||||||
|
|
||||||
|
A2A calls are synchronous (`message/send` only: no `message/stream`, and the AgentCard says so
|
||||||
|
truthfully in `capabilities.streaming`) — /api/chat and the AG-UI protocol are where this app's
|
||||||
|
streaming surface is. Since there is no browser to run a tool and report back, this ns runs the
|
||||||
|
tool loop itself: ask the agent, and if it asks for tools, run them immediately and ask again,
|
||||||
|
until it answers in text or `max-turns` is reached.
|
||||||
|
|
||||||
|
Protected the same way /mcp is (tbd.oauth.middleware/wrap-bearer-auth): any access token this
|
||||||
|
server issued works here too, since verifying one doesn't check which `resource` it was minted
|
||||||
|
for (see tbd.oauth.middleware) — a client that has authorized for MCP can use A2A with the same
|
||||||
|
token."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[malli.json-schema :as json-schema]
|
||||||
|
[muuntaja.core :as m]
|
||||||
|
[tbd.agent.core :as agent]
|
||||||
|
[tbd.agent.instructions :as instructions]
|
||||||
|
[tbd.math.core :as math]
|
||||||
|
[tbd.math.schema :as math-schema]
|
||||||
|
[tbd.mcp.schema :refer [coerce!]]
|
||||||
|
[tbd.oauth.middleware :as oauth-mw]
|
||||||
|
[tbd.todo.schema :as todo-schema]
|
||||||
|
[tbd.todo.service :as todo]))
|
||||||
|
|
||||||
|
(def ^:private max-turns
|
||||||
|
"How many rounds of tool calls the agent may make before answering, for one A2A message."
|
||||||
|
8)
|
||||||
|
|
||||||
|
;; --- Tools: the same operations tbd.mcp.tools offers MCP clients, for a caller with no page ---
|
||||||
|
|
||||||
|
(defn- ok! [{:keys [ok error]}]
|
||||||
|
(if error (throw (ex-info error {})) ok))
|
||||||
|
|
||||||
|
(defn- ->json [data] (slurp (m/encode "application/json" data)))
|
||||||
|
|
||||||
|
(defn- tool [name description schema handler]
|
||||||
|
{:name name :description description
|
||||||
|
:inputSchema (json-schema/transform schema)
|
||||||
|
:handler (fn [args {:keys [user ds]}] (str (handler (coerce! schema args) user ds)))})
|
||||||
|
|
||||||
|
(def tools
|
||||||
|
[(tool "add" "Add two numbers" math-schema/Add
|
||||||
|
(fn [{:keys [x y]} _ _] (math/add x y)))
|
||||||
|
(tool "whoami" "Says which user this call acts as, and whether they are an admin" [:map]
|
||||||
|
(fn [_ {:keys [login id admin?]} _]
|
||||||
|
(format "%s (Gitea user %d)%s" login id (if admin? ", admin" ""))))
|
||||||
|
(tool "list_todos" "Lists your todos (JSON: id, title, done, created-at, updated-at), oldest first" [:map]
|
||||||
|
(fn [_ user ds] (->json (ok! (todo/list-todos ds user)))))
|
||||||
|
(tool "get_todo" "Gets one of your todos by id (JSON)" todo-schema/IdParam
|
||||||
|
(fn [{:keys [id]} user ds] (->json (ok! (todo/find-todo ds user id)))))
|
||||||
|
(tool "create_todo" "Creates a todo for you and returns it (JSON)" todo-schema/NewTodo
|
||||||
|
(fn [{:keys [title]} user ds] (->json (ok! (todo/create! ds user title)))))
|
||||||
|
(tool "update_todo" "Changes the title and/or done flag of one of your todos and returns it (JSON)"
|
||||||
|
todo-schema/UpdateTodo
|
||||||
|
(fn [{:keys [id] :as input} user ds] (->json (ok! (todo/update! ds user id (dissoc input :id))))))
|
||||||
|
(tool "delete_todo" "Deletes one of your todos" todo-schema/IdParam
|
||||||
|
(fn [{:keys [id]} user ds] (ok! (todo/delete! ds user id)) "deleted"))])
|
||||||
|
|
||||||
|
(defn- execute-tool [ctx {:keys [name arguments]}]
|
||||||
|
(if-let [{:keys [handler]} (first (filter #(= name (:name %)) tools))]
|
||||||
|
(try (handler (m/decode "application/json" (or arguments "{}")) ctx)
|
||||||
|
(catch Exception e (str "error: " (ex-message e))))
|
||||||
|
(str "error: no such tool " (pr-str name))))
|
||||||
|
|
||||||
|
;; --- Driving the agent, running its own tool loop (no browser here) ---
|
||||||
|
|
||||||
|
(defn- collect
|
||||||
|
"Drains `pump` (see tbd.agent.core/start), returning {:text s} or {:tool-calls [...]} or
|
||||||
|
{:error s}: exactly one of them, whichever the turn ended with."
|
||||||
|
[pump]
|
||||||
|
(let [text (StringBuilder.) result (atom nil)]
|
||||||
|
(pump (fn [{:keys [content tool-calls done error]}]
|
||||||
|
(cond
|
||||||
|
content (.append text ^String content)
|
||||||
|
tool-calls (reset! result {:tool-calls tool-calls})
|
||||||
|
error (reset! result {:error error})
|
||||||
|
done (when-not @result (reset! result {:text (.toString text)})))))
|
||||||
|
@result))
|
||||||
|
|
||||||
|
(defn- run-tools!
|
||||||
|
"Answers `messages` (tbd.agent.core's internal Message shape), running any tool calls the agent
|
||||||
|
makes itself, `ctx` ({:user :ds}) being what the tools need. {:text s} or {:error s :kind ...}."
|
||||||
|
[config ctx messages]
|
||||||
|
(loop [messages messages turn 0]
|
||||||
|
(if (>= turn max-turns)
|
||||||
|
{:error "the agent didn't reach an answer in time" :kind :upstream}
|
||||||
|
(let [{:keys [ok error kind]} (agent/start config (instructions/a2a) messages tools)]
|
||||||
|
(if error
|
||||||
|
{:error error :kind kind}
|
||||||
|
(let [{:keys [text tool-calls error]} (collect ok)]
|
||||||
|
(cond
|
||||||
|
error {:error error :kind :upstream}
|
||||||
|
(seq tool-calls)
|
||||||
|
(recur (into (conj (vec messages) {:role "assistant" :content "" :tool-calls tool-calls})
|
||||||
|
(map (fn [{:keys [id name arguments]}]
|
||||||
|
{:role "tool" :tool-call-id id
|
||||||
|
:content (execute-tool ctx {:name name :arguments arguments})}))
|
||||||
|
tool-calls)
|
||||||
|
(inc turn))
|
||||||
|
:else {:text text})))))))
|
||||||
|
|
||||||
|
(defn- message-text
|
||||||
|
"The plain text of an A2A Message's `parts` ([{kind: \"text\" text: \"...\"}, ...]), joined."
|
||||||
|
[parts]
|
||||||
|
(->> parts (filter #(= "text" (or (:kind %) (get % "kind")))) (map #(or (:text %) (get % "text"))) (str/join "\n")))
|
||||||
|
|
||||||
|
(defn- agent-message
|
||||||
|
"A reply as an A2A Message: role \"agent\", one TextPart."
|
||||||
|
[text]
|
||||||
|
{:role "agent" :parts [{:kind "text" :text text}] :messageId (str (random-uuid)) :kind "message"})
|
||||||
|
|
||||||
|
;; --- JSON-RPC over HTTP ---
|
||||||
|
|
||||||
|
(defn- json [status body]
|
||||||
|
{:status status
|
||||||
|
:headers {"Content-Type" "application/json" "Cache-Control" "no-store"}
|
||||||
|
:body (m/encode "application/json" body)})
|
||||||
|
|
||||||
|
(defn- rpc-result [id result] {:jsonrpc "2.0" :id id :result result})
|
||||||
|
(defn- rpc-error [id code message] {:jsonrpc "2.0" :id id :error {:code code :message message}})
|
||||||
|
|
||||||
|
(def ^:private error-status {:unavailable 503 :upstream 502})
|
||||||
|
|
||||||
|
(defn- handle-message-send [config ctx {:keys [message]}]
|
||||||
|
(let [text (message-text (:parts message))]
|
||||||
|
(if (str/blank? text)
|
||||||
|
{:jsonrpc-error [-32602 "message has no text parts"]}
|
||||||
|
(let [{:keys [text error kind]} (run-tools! config ctx [{:role "user" :content text}])]
|
||||||
|
(if error
|
||||||
|
{:error [(get error-status kind 502) error]}
|
||||||
|
{:result (agent-message text)})))))
|
||||||
|
|
||||||
|
(defn- handler [config ds]
|
||||||
|
(fn [{:keys [user body]}]
|
||||||
|
(let [{:keys [jsonrpc id method params]} (m/decode "application/json" (slurp body))]
|
||||||
|
(cond
|
||||||
|
(not= "2.0" jsonrpc)
|
||||||
|
(json 200 (rpc-error id -32600 "invalid JSON-RPC request"))
|
||||||
|
|
||||||
|
(= "message/send" method)
|
||||||
|
(let [{:keys [result jsonrpc-error error]} (handle-message-send config {:user user :ds ds} params)]
|
||||||
|
(cond
|
||||||
|
result (json 200 (rpc-result id result))
|
||||||
|
jsonrpc-error (json 200 (apply rpc-error id jsonrpc-error))
|
||||||
|
:else (let [[status text] error] (json status (rpc-error id -32000 text)))))
|
||||||
|
|
||||||
|
:else (json 200 (rpc-error id -32601 (str "method not found: " method)))))))
|
||||||
|
|
||||||
|
(defn- agent-card [base-url]
|
||||||
|
{:name "tbd"
|
||||||
|
:description "tbd's agent: todo management and simple math, on behalf of the user who authorized this connection."
|
||||||
|
:url (str base-url "/a2a")
|
||||||
|
:version "1.0.0"
|
||||||
|
:protocolVersion "0.3.0"
|
||||||
|
:preferredTransport "JSONRPC"
|
||||||
|
:capabilities {:streaming false :pushNotifications false}
|
||||||
|
:defaultInputModes ["text/plain"]
|
||||||
|
:defaultOutputModes ["text/plain"]
|
||||||
|
:securitySchemes {"bearer" {:type "http" :scheme "bearer"
|
||||||
|
:description "An OAuth access token from this server's own authorization server (see /.well-known/oauth-protected-resource/mcp; the same token works here)."}}
|
||||||
|
:security [{"bearer" []}]
|
||||||
|
:skills (mapv (fn [{:keys [name description]}]
|
||||||
|
{:id name :name name :description description :tags ["tbd"]})
|
||||||
|
tools)})
|
||||||
|
|
||||||
|
(defn routes
|
||||||
|
"`auth` is the auth config (for `:base-url` and the OAuth check), `ds` a delay of the app's
|
||||||
|
database, `chat` the agent config (see tbd.agent.core/env-config)."
|
||||||
|
[auth ds chat]
|
||||||
|
["" {:no-doc true}
|
||||||
|
["/.well-known/agent-card.json"
|
||||||
|
{:middleware ^:replace []
|
||||||
|
:get {:handler (fn [_req] (json 200 (agent-card (:base-url auth))))}}]
|
||||||
|
["/a2a"
|
||||||
|
{:middleware ^:replace []
|
||||||
|
:post {:handler (-> (handler chat ds) (oauth-mw/wrap-bearer-auth auth ds))}}]])
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
(ns tbd.agent.core
|
||||||
|
"The chatbot's agent: a LangChain4j `StreamingChatModel` talking to the OpenAI-compatible LM
|
||||||
|
Studio server named by the environment. No Ring here; tbd.chat.routes owns that, tbd.agent.a2a
|
||||||
|
drives the same model for the A2A server.
|
||||||
|
|
||||||
|
`start` sends the request and gives {:ok pump} or {:error text :kind :unavailable|:upstream},
|
||||||
|
like tbd.todo.service's. `pump` forwards the reply, as it is generated, to a function that takes
|
||||||
|
events: {:thinking \"text\"} (a piece of the model's reasoning), {:content \"text\"} (a piece of
|
||||||
|
the reply), {:tool-calls [{:id :name :arguments}]} (the model wants these tools run, once its
|
||||||
|
reply is complete: `arguments` is the JSON text the model wrote), and finally {:done true} or
|
||||||
|
{:error \"text\"}. This event shape is internal (tbd.chat.routes and tbd.agent.a2a each translate
|
||||||
|
it to their own wire protocol — AG-UI events, A2A artifacts) and is what tbd.chat.trace watches.
|
||||||
|
|
||||||
|
The system prompt is the agent's own (tbd.agent.instructions), not something the caller supplies:
|
||||||
|
`start` takes `system-text` already composed (tbd.agent.instructions/chat-system-message for the
|
||||||
|
in-page chatbot, tbd.agent.instructions/a2a for the A2A server), and always sends it as the first
|
||||||
|
message. `tools` are MCP-style {:name :description :inputSchema}, converted to LangChain4j's
|
||||||
|
typed tool specifications (tbd.agent.json-schema) — the model only sees their metadata; running
|
||||||
|
them and reporting the result back (as a \"tool\" role message, see tbd.chat.schema/Message) is
|
||||||
|
the caller's job.
|
||||||
|
|
||||||
|
Every turn is traced (see tbd.chat.trace): what was asked, how the reply went, and whether the
|
||||||
|
model looks stuck in a loop. The config's `:trace` says where that goes."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[tbd.agent.json-schema :as json-schema]
|
||||||
|
[tbd.chat.trace :as trace])
|
||||||
|
(:import (dev.langchain4j.agent.tool ToolExecutionRequest ToolSpecification)
|
||||||
|
(dev.langchain4j.data.message AiMessage ChatMessage SystemMessage ToolExecutionResultMessage UserMessage)
|
||||||
|
(dev.langchain4j.http.client.jdk JdkHttpClientBuilder)
|
||||||
|
(dev.langchain4j.model.chat StreamingChatModel)
|
||||||
|
(dev.langchain4j.model.chat.request ChatRequest)
|
||||||
|
(dev.langchain4j.model.chat.response
|
||||||
|
ChatResponse CompleteToolCall PartialResponse PartialResponseContext
|
||||||
|
PartialThinking PartialThinkingContext PartialToolCallContext
|
||||||
|
StreamingChatResponseHandler StreamingHandle)
|
||||||
|
(dev.langchain4j.model.openai OpenAiStreamingChatModel)
|
||||||
|
(java.net.http HttpClient HttpClient$Version)
|
||||||
|
(java.time Duration)
|
||||||
|
(java.util.concurrent LinkedBlockingQueue ScheduledFuture ScheduledThreadPoolExecutor ThreadFactory TimeUnit)))
|
||||||
|
|
||||||
|
(def default-timeout-seconds 120)
|
||||||
|
|
||||||
|
(defn- env-val [env k]
|
||||||
|
(let [v (get env k)]
|
||||||
|
(when-not (str/blank? v) (str/trim v))))
|
||||||
|
|
||||||
|
;; HTTP/1.1 on purpose, same reason as the old tbd.chat.core: the JDK client's default is to ask a
|
||||||
|
;; plain-http server to upgrade to HTTP/2 (`Upgrade: h2c`), and LM Studio's server never answers
|
||||||
|
;; that request, so the backend would wait out the whole timeout while the LLM sat idle.
|
||||||
|
(defn- jdk-http-client-builder []
|
||||||
|
(-> (JdkHttpClientBuilder.)
|
||||||
|
(.httpClientBuilder (-> (HttpClient/newBuilder) (.version HttpClient$Version/HTTP_1_1)))))
|
||||||
|
|
||||||
|
(defn- api-base-url
|
||||||
|
"`url` (the server's address, no path, see env-config) with the `/v1` LM Studio's OpenAI-compatible
|
||||||
|
endpoints live under: LangChain4j's OpenAI client posts straight to `<baseUrl>/chat/completions`,
|
||||||
|
it does not add this itself (its own default base url is `https://api.openai.com/v1`). Without
|
||||||
|
it, LM Studio answers 200 with a JSON error body langchain4j can't tell from an empty reply, so
|
||||||
|
every turn silently becomes \"the LLM server sent no reply\" instead of a clear error."
|
||||||
|
[url]
|
||||||
|
(if (str/ends-with? url "/v1") url (str url "/v1")))
|
||||||
|
|
||||||
|
(defn- build-chat-model ^StreamingChatModel [{:keys [url model api-key timeout-seconds]}]
|
||||||
|
(let [builder (-> (OpenAiStreamingChatModel/builder)
|
||||||
|
(.httpClientBuilder (jdk-http-client-builder))
|
||||||
|
(.baseUrl (api-base-url url))
|
||||||
|
(.apiKey (or api-key "not-needed"))
|
||||||
|
(.timeout (Duration/ofSeconds timeout-seconds))
|
||||||
|
;; LM Studio's reasoning models send `reasoning_content`; this is what maps it
|
||||||
|
;; onto onPartialThinking instead of dropping it.
|
||||||
|
(.returnThinking true))]
|
||||||
|
(when model (.modelName builder model))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
(defn env-config
|
||||||
|
"Chat config from environment variables:
|
||||||
|
LMSTUDIO_URL where the LM Studio server is, e.g. http://192.168.1.20:1234 (chat is off without it)
|
||||||
|
LMSTUDIO_MODEL the model id to ask for (optional: LM Studio uses the model that is loaded)
|
||||||
|
LMSTUDIO_API_KEY sent as a bearer token, when the server wants one (optional)
|
||||||
|
LMSTUDIO_TIMEOUT_SECONDS how long the server may stay silent before giving up (default 120)
|
||||||
|
CHAT_TRACE_FILE a file to append a JSON trace of every turn to, with the model's full reasoning and
|
||||||
|
replies and the messages it was sent (optional: without it, each turn is only
|
||||||
|
summarised in the log)"
|
||||||
|
([] (env-config (System/getenv)))
|
||||||
|
([env]
|
||||||
|
(let [config {:url (some-> (env-val env "LMSTUDIO_URL") (str/replace #"/+$" ""))
|
||||||
|
:model (env-val env "LMSTUDIO_MODEL")
|
||||||
|
:api-key (env-val env "LMSTUDIO_API_KEY")
|
||||||
|
:timeout-seconds (or (some-> (env-val env "LMSTUDIO_TIMEOUT_SECONDS") parse-long)
|
||||||
|
default-timeout-seconds)
|
||||||
|
:trace (trace/config-for (env-val env "CHAT_TRACE_FILE"))}]
|
||||||
|
(assoc config :chat-model-delay (delay (build-chat-model config))))))
|
||||||
|
|
||||||
|
(defn configured? [{:keys [url]}] (some? url))
|
||||||
|
|
||||||
|
(defn- chat-model
|
||||||
|
"The config's `StreamingChatModel`: `:chat-model` directly, when a caller (a test) gives one, else
|
||||||
|
the one built from `:url`/`:model`/`:api-key`/`:timeout-seconds`, built once and reused."
|
||||||
|
^StreamingChatModel [config]
|
||||||
|
(or (:chat-model config) (force (:chat-model-delay config))))
|
||||||
|
|
||||||
|
;; --- Building the request ---
|
||||||
|
|
||||||
|
(defn- llm-message
|
||||||
|
"One message of the conversation (see tbd.chat.schema/Message) as a LangChain4j ChatMessage."
|
||||||
|
^ChatMessage [{:keys [role content tool-calls tool-call-id]}]
|
||||||
|
(case role
|
||||||
|
"user" (UserMessage/from ^String content)
|
||||||
|
"assistant" (if (seq tool-calls)
|
||||||
|
(AiMessage/from (str content)
|
||||||
|
^java.util.List
|
||||||
|
(mapv (fn [{:keys [id name arguments]}]
|
||||||
|
(-> (ToolExecutionRequest/builder)
|
||||||
|
(.id id) (.name name) (.arguments arguments)
|
||||||
|
.build))
|
||||||
|
tool-calls))
|
||||||
|
(AiMessage/from ^String content))
|
||||||
|
"tool" (ToolExecutionResultMessage/toolExecutionResultMessage
|
||||||
|
^String tool-call-id "" ^String content)))
|
||||||
|
|
||||||
|
(defn- llm-tool
|
||||||
|
"An MCP tool's metadata as a function the LLM may call."
|
||||||
|
^ToolSpecification [{:keys [name description inputSchema]}]
|
||||||
|
(let [builder (-> (ToolSpecification/builder)
|
||||||
|
(.name name)
|
||||||
|
(.parameters (json-schema/tool-parameters inputSchema)))]
|
||||||
|
(when description (.description builder description))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
(defn- chat-request ^ChatRequest [system-text messages tools]
|
||||||
|
(let [builder (-> (ChatRequest/builder)
|
||||||
|
(.messages ^java.util.List (into [(SystemMessage/from ^String system-text)]
|
||||||
|
(map llm-message) messages)))]
|
||||||
|
(when (seq tools)
|
||||||
|
(.toolSpecifications builder ^java.util.List (mapv llm-tool tools)))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
;; --- Reading the reply ---
|
||||||
|
|
||||||
|
(def ^:private watchdog
|
||||||
|
(delay (doto (ScheduledThreadPoolExecutor.
|
||||||
|
1 (reify ThreadFactory
|
||||||
|
(newThread [_ r] (doto (Thread. ^Runnable r "chat-watchdog") (.setDaemon true)))))
|
||||||
|
(.setRemoveOnCancelPolicy true))))
|
||||||
|
|
||||||
|
(defn- exception-detail [^Throwable e]
|
||||||
|
(str (class e) ": " (.getMessage e)))
|
||||||
|
|
||||||
|
(defn- fail
|
||||||
|
([text] (fail text nil))
|
||||||
|
([text detail]
|
||||||
|
(println (cond-> (str "[tbd] chat: " text) detail (str " (" detail ")")))
|
||||||
|
text))
|
||||||
|
|
||||||
|
(defn- streaming-handler
|
||||||
|
"A StreamingChatResponseHandler that pushes every callback onto `queue` as a map: {:content s},
|
||||||
|
{:thinking s}, {:tool-call {:id :name :arguments}} (once a call is fully assembled — LangChain4j
|
||||||
|
does the delta accumulation itself), {:complete response} or {:error throwable}. `handle!` is
|
||||||
|
called with the first StreamingHandle seen, so the caller can cancel a stuck stream."
|
||||||
|
^StreamingChatResponseHandler [^LinkedBlockingQueue queue handle!]
|
||||||
|
(let [capture! (fn [^StreamingHandle h] (when h (handle! h)))]
|
||||||
|
;; No type hints on the reify methods' params below: hinting an overloaded interface method's
|
||||||
|
;; params (onPartialResponse/onPartialThinking/onPartialToolCall each have a 1- and a 2-arg
|
||||||
|
;; overload) confuses Clojure's overload resolution (`NewInstanceMethod.parse`), which throws
|
||||||
|
;; "Mismatched return type" instead of picking the right one. Arity alone disambiguates fine.
|
||||||
|
(reify StreamingChatResponseHandler
|
||||||
|
(onPartialResponse [_ partial ctx]
|
||||||
|
(capture! (.streamingHandle ^PartialResponseContext ctx))
|
||||||
|
(.put queue {:content (.text ^PartialResponse partial)})
|
||||||
|
nil)
|
||||||
|
(onPartialThinking [_ partial ctx]
|
||||||
|
(capture! (.streamingHandle ^PartialThinkingContext ctx))
|
||||||
|
(.put queue {:thinking (.text ^PartialThinking partial)})
|
||||||
|
nil)
|
||||||
|
(onPartialToolCall [_ _partial ctx]
|
||||||
|
(capture! (.streamingHandle ^PartialToolCallContext ctx))
|
||||||
|
nil)
|
||||||
|
(onCompleteToolCall [_ complete]
|
||||||
|
(let [req (.toolExecutionRequest ^CompleteToolCall complete)]
|
||||||
|
(.put queue {:tool-call {:id (.id req) :name (.name req) :arguments (.arguments req)}}))
|
||||||
|
nil)
|
||||||
|
(onCompleteResponse [_ response]
|
||||||
|
(.put queue {:complete response})
|
||||||
|
nil)
|
||||||
|
(onError [_ error]
|
||||||
|
(.put queue {:error error})
|
||||||
|
nil))))
|
||||||
|
|
||||||
|
(defn- usage-of [^ChatResponse response]
|
||||||
|
(when-let [usage (.tokenUsage response)]
|
||||||
|
{:prompt_tokens (.inputTokenCount usage)
|
||||||
|
:completion_tokens (.outputTokenCount usage)
|
||||||
|
:total_tokens (.totalTokenCount usage)}))
|
||||||
|
|
||||||
|
(defn- finish-reason-of [^ChatResponse response]
|
||||||
|
(some-> (.finishReason response) str str/lower-case))
|
||||||
|
|
||||||
|
(defn- pump
|
||||||
|
"Drains `queue`, starting from the already-polled `first-item` (see `start`, which peeks one item
|
||||||
|
before deciding {:ok}/{:error}), calling `emit` with each event (see the ns docstring).
|
||||||
|
`timeout-seconds` of silence between items is an error, closing the stream via `handle-atom` (a
|
||||||
|
StreamingHandle, once one has arrived). An exception from `emit` (the client went away, say) ends
|
||||||
|
it too, closing the stream, and is rethrown. `tracer` (see tbd.chat.trace) sees everything the
|
||||||
|
LLM says, and how the turn ended, before the client is told."
|
||||||
|
[^LinkedBlockingQueue queue handle-atom timeout-seconds emit tracer first-item]
|
||||||
|
(let [timer (atom nil)
|
||||||
|
timed-out? (atom false)
|
||||||
|
cancel-stream! (fn []
|
||||||
|
(when-let [^StreamingHandle h @handle-atom]
|
||||||
|
(try (.cancel h) (catch Exception _ nil))))
|
||||||
|
arm! (fn []
|
||||||
|
(some-> ^ScheduledFuture @timer (.cancel false))
|
||||||
|
(reset! timer (.schedule ^ScheduledThreadPoolExecutor @watchdog
|
||||||
|
^Runnable (fn [] (reset! timed-out? true) (cancel-stream!))
|
||||||
|
(long timeout-seconds) TimeUnit/SECONDS)))]
|
||||||
|
(arm!)
|
||||||
|
(try
|
||||||
|
(loop [item first-item
|
||||||
|
replied? false
|
||||||
|
calls []]
|
||||||
|
(cond
|
||||||
|
(:error item)
|
||||||
|
(let [^Throwable e (:error item)
|
||||||
|
text (if @timed-out?
|
||||||
|
(fail "the LLM server took too long to answer" (str timeout-seconds "s of silence"))
|
||||||
|
(fail "the LLM server can't be reached or refused" (exception-detail e)))]
|
||||||
|
(trace/finish! tracer {:outcome :error :error text :detail (exception-detail e)})
|
||||||
|
(emit {:error text}))
|
||||||
|
|
||||||
|
(:complete item)
|
||||||
|
(let [^ChatResponse response (:complete item)
|
||||||
|
ai (.aiMessage response)
|
||||||
|
calls (if (seq calls) calls
|
||||||
|
(mapv (fn [^ToolExecutionRequest r]
|
||||||
|
{:id (.id r) :name (.name r) :arguments (.arguments r)})
|
||||||
|
(.toolExecutionRequests ai)))
|
||||||
|
;; A provider that never streamed tokens (batched the whole reply) has its text
|
||||||
|
;; only here, never through :content items above.
|
||||||
|
batched-text (when (and (not replied?) (empty? calls) (not (str/blank? (.text ai))))
|
||||||
|
(.text ai))
|
||||||
|
event {:finish-reason (finish-reason-of response) :usage (usage-of response)}]
|
||||||
|
(trace/observe! tracer event)
|
||||||
|
(cond
|
||||||
|
(seq calls) (do (trace/finish! tracer {:outcome :tool-calls :calls calls})
|
||||||
|
(emit {:tool-calls calls})
|
||||||
|
(emit {:done true}))
|
||||||
|
batched-text (do (trace/finish! tracer {:outcome :done})
|
||||||
|
(emit {:content batched-text})
|
||||||
|
(emit {:done true}))
|
||||||
|
(or replied? (not (str/blank? (.text ai))))
|
||||||
|
(do (trace/finish! tracer {:outcome :done}) (emit {:done true}))
|
||||||
|
:else (let [text (fail "the LLM server sent no reply")]
|
||||||
|
(trace/finish! tracer {:outcome :error :error text})
|
||||||
|
(emit {:error text}))))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(do (arm!)
|
||||||
|
(trace/observe! tracer (select-keys item [:content :thinking]))
|
||||||
|
(when-let [out (not-empty (select-keys item [:content :thinking]))] (emit out))
|
||||||
|
(recur (.take queue)
|
||||||
|
(or replied? (contains? item :content))
|
||||||
|
(cond-> calls (:tool-call item) (conj (:tool-call item)))))))
|
||||||
|
(finally
|
||||||
|
(trace/finish! tracer {:outcome :aborted})
|
||||||
|
(some-> ^ScheduledFuture @timer (.cancel false))
|
||||||
|
(cancel-stream!)))))
|
||||||
|
|
||||||
|
(defn start
|
||||||
|
"Sends the LLM the conversation `messages` (see tbd.chat.schema/Message), and the `tools` it may
|
||||||
|
ask the caller to run (MCP's {:name :description :inputSchema}; none if nil), after `system-text`
|
||||||
|
(the agent's own system prompt, see tbd.agent.instructions — composed by the caller, not the
|
||||||
|
browser). Waits for the first sign of life from the model (a piece of its reply, or an error) so
|
||||||
|
that an LLM that is down or refuses is still a clean {:error text :kind :unavailable|:upstream},
|
||||||
|
not an error inside a stream that has already begun — the same contract the old hand-rolled HTTP
|
||||||
|
client gave, adapted to LangChain4j's async callbacks (which have no separate \"got the HTTP
|
||||||
|
response\" moment to block on the way a blocking HTTP client's `.send()` did). Otherwise {:ok
|
||||||
|
pump}: `(pump emit)` blocks until the reply is complete, calling `emit` with its events (see the
|
||||||
|
ns docstring). If `emit` throws, the request to the LLM is closed (which stops it generating) and
|
||||||
|
the exception is rethrown."
|
||||||
|
[{:keys [timeout-seconds trace] :as config} system-text messages tools]
|
||||||
|
(if-not (configured? config)
|
||||||
|
{:error "chat is not configured" :kind :unavailable}
|
||||||
|
(let [tracer (trace/begin! trace {:messages messages :tools tools :model (:model config)})
|
||||||
|
upstream-error (fn [text detail]
|
||||||
|
(fail text detail)
|
||||||
|
(trace/finish! tracer {:outcome :upstream-error :error text :detail detail})
|
||||||
|
{:error text :kind :upstream})]
|
||||||
|
(try
|
||||||
|
(let [queue (LinkedBlockingQueue.)
|
||||||
|
handle-atom (atom nil)
|
||||||
|
handler (streaming-handler queue #(reset! handle-atom %))]
|
||||||
|
(.chat (chat-model config) (chat-request system-text messages tools) handler)
|
||||||
|
(if-let [first-item (.poll queue (long timeout-seconds) TimeUnit/SECONDS)]
|
||||||
|
(if (:error first-item)
|
||||||
|
(upstream-error "the LLM server can't be reached or refused" (exception-detail (:error first-item)))
|
||||||
|
{:ok (fn [emit] (pump queue handle-atom timeout-seconds emit tracer first-item))})
|
||||||
|
(upstream-error "the LLM server took too long to answer" (str timeout-seconds "s of silence"))))
|
||||||
|
(catch Exception e
|
||||||
|
(upstream-error "the LLM server can't be reached" (exception-detail e)))))))
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
(ns tbd.agent.instructions
|
||||||
|
"The system prompts of the two agents tbd.agent.core drives: `chat`, for the in-page chatbot
|
||||||
|
behind /api/chat, and `a2a`, for the A2A server (tbd.agent.a2a) other agents call. Server-only
|
||||||
|
on purpose: unlike tbd.agent.prompt (a .cljc the browser bundle also needs, for WebMCP's
|
||||||
|
`guide`/`task`/`tic-tac-toe`), this ns is never on ui/shadow-cljs.edn's `:source-paths`, so the
|
||||||
|
chatbot's own instructions can never end up in the compiled UI. It reuses the wording that is
|
||||||
|
genuinely shared (`act`, `work-it-out`, `tic-tac-toe-rules`, `examples`) from tbd.agent.prompt
|
||||||
|
rather than duplicating it."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[tbd.agent.prompt :as prompt]))
|
||||||
|
|
||||||
|
(defn chat
|
||||||
|
"The in-page chatbot's system prompt, the start of its system message: tbd.agent.core adds
|
||||||
|
where the user is and the tools it has. Its calls show under its message, so it answers in a
|
||||||
|
sentence and never repeats them."
|
||||||
|
[]
|
||||||
|
(str "You are the assistant built into this web app. Your tools type, click and read on the page the\n"
|
||||||
|
"user is looking at, and every call shows on their screen the moment you make it.\n"
|
||||||
|
"\n"
|
||||||
|
prompt/act
|
||||||
|
(prompt/work-it-out (str "- Chain calls without stopping. The tool you need belongs to another page: navigate there, then\n"
|
||||||
|
" use it. You need an id: list first, then act. Never invent an id, a title or the state of the\n"
|
||||||
|
" page: read it with a tool.\n"))
|
||||||
|
"Then say one short plain sentence: \"Added it.\" \"That's 23.\" The call and its result are already\n"
|
||||||
|
"on screen under your message, so never repeat them, never paste JSON, never use markdown,\n"
|
||||||
|
"headings or bullet lists. Say something was done only if a tool result says it was.\n"
|
||||||
|
"\n"
|
||||||
|
"Some things are not tool work: greetings, questions about you, questions you can simply answer.\n"
|
||||||
|
"Answer those in a sentence or two, with no tool call. Asked what you can do, say it plainly from\n"
|
||||||
|
"the tools you have. Think briefly before you act.\n"
|
||||||
|
"\n"
|
||||||
|
prompt/tic-tac-toe-rules
|
||||||
|
prompt/examples))
|
||||||
|
|
||||||
|
(defn chat-system-message
|
||||||
|
"`chat`'s prompt, with, last, where the user is (`page`) and the names of the `tools` on offer
|
||||||
|
there. The tools' names are in the request's `tools` too, but a model picks them more readily
|
||||||
|
when the instructions say them as well, and it can say what it can do without a call. One
|
||||||
|
message, and the first: several chat templates (Gemma's, Mistral's) refuse more than one system
|
||||||
|
message, or one that isn't first."
|
||||||
|
[page tools]
|
||||||
|
(cond-> (chat)
|
||||||
|
page (str "\n\nThe user is on the \"" page "\" page of the app: what it shows is what they can see.")
|
||||||
|
(seq tools) (str (if page " Its" "\n\nYour")
|
||||||
|
" tools: " (str/join ", " (map :name tools))
|
||||||
|
". The tools of other pages are offered once you navigate there.")))
|
||||||
|
|
||||||
|
(defn a2a
|
||||||
|
"The A2A server's system prompt: an outside agent calling this app directly, not through a
|
||||||
|
page, so it has no DOM to act on and no visual feedback to lean on. Its tools are the app's
|
||||||
|
server-side ones (tbd.mcp.tools, the same the MCP server offers), not a page's."
|
||||||
|
[]
|
||||||
|
(str "You are tbd's agent, reachable over the Agent2Agent protocol. You act on behalf of the user\n"
|
||||||
|
"who authorized the caller, using the tools you were given: todo management and anything else\n"
|
||||||
|
"this server exposes as a tool. You have no page open and no visual feedback beyond what a tool\n"
|
||||||
|
"returns.\n"
|
||||||
|
"\n"
|
||||||
|
prompt/act
|
||||||
|
(prompt/work-it-out (str "- You need an id: list first, then act. Never invent an id, a title or the state of the data:\n"
|
||||||
|
" read it with a tool.\n"))
|
||||||
|
"Then report in one short plain sentence, from the tool results: \"Added it.\" \"That's 23.\" Don't\n"
|
||||||
|
"paste JSON, don't describe the tool call itself, just its outcome.\n"
|
||||||
|
"\n"
|
||||||
|
prompt/examples))
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
(ns tbd.agent.json-schema
|
||||||
|
"Translates a tool's `inputSchema` (plain JSON Schema, decoded to a Clojure map — see
|
||||||
|
tbd.chat.schema/Tool) into LangChain4j's typed `JsonSchemaElement` tree, which is what
|
||||||
|
`ToolSpecification.Builder/parameters` needs (dev.langchain4j.model.chat.request.json). The
|
||||||
|
browser is the one authoring these schemas (tbd.ui.webmcp), so this only has to cover the shapes
|
||||||
|
MCP-style tool schemas actually use: object/string/number/integer/boolean/array/enum, nested
|
||||||
|
arbitrarily. Anything else (a bare `:enum` without a `:type`, an unrecognized `:type`) falls back
|
||||||
|
to a permissive `JsonRawSchema` dump of the schema as JSON, rather than failing the request."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[muuntaja.core :as m])
|
||||||
|
(:import (dev.langchain4j.model.chat.request.json
|
||||||
|
JsonArraySchema JsonBooleanSchema JsonEnumSchema JsonIntegerSchema
|
||||||
|
JsonNumberSchema JsonObjectSchema JsonRawSchema JsonSchemaElement JsonStringSchema)))
|
||||||
|
|
||||||
|
(defn- field
|
||||||
|
"`m`'s value at `k`, trying the keyword first (how muuntaja decodes JSON) then the string, so
|
||||||
|
this works whether or not something upstream keywordized the nested schema too."
|
||||||
|
[m k]
|
||||||
|
(or (get m (keyword k)) (get m (name k))))
|
||||||
|
|
||||||
|
(defn- raw
|
||||||
|
"A schema element that isn't one of the shapes below: dumped as JSON and wrapped in
|
||||||
|
`JsonRawSchema` rather than dropped, so the LLM still sees it."
|
||||||
|
[schema]
|
||||||
|
(JsonRawSchema/from (slurp (m/encode "application/json" schema))))
|
||||||
|
|
||||||
|
(declare of)
|
||||||
|
|
||||||
|
(defn- object-schema ^JsonObjectSchema [schema]
|
||||||
|
(let [builder (JsonObjectSchema/builder)]
|
||||||
|
(doseq [[k v] (field schema :properties)]
|
||||||
|
(.addProperty builder (name k) (of v)))
|
||||||
|
(when-let [required (seq (field schema :required))]
|
||||||
|
(.required builder ^java.util.List (mapv name required)))
|
||||||
|
(when-let [d (field schema :description)]
|
||||||
|
(.description builder d))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
(defn- array-schema ^JsonArraySchema [schema]
|
||||||
|
(let [builder (JsonArraySchema/builder)]
|
||||||
|
(when-let [items (field schema :items)]
|
||||||
|
(.items builder (of items)))
|
||||||
|
(when-let [d (field schema :description)]
|
||||||
|
(.description builder d))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
(defn- enum-schema ^JsonEnumSchema [schema enum-values]
|
||||||
|
(let [builder (.enumValues (JsonEnumSchema/builder) ^java.util.List (mapv str enum-values))]
|
||||||
|
(when-let [d (field schema :description)]
|
||||||
|
(.description builder d))
|
||||||
|
(.build builder)))
|
||||||
|
|
||||||
|
(defmacro ^:private simple-schema [ctor schema]
|
||||||
|
`(let [builder# (~ctor)]
|
||||||
|
(when-let [d# (field ~schema :description)]
|
||||||
|
(.description builder# d#))
|
||||||
|
(.build builder#)))
|
||||||
|
|
||||||
|
(defn of
|
||||||
|
"`schema` (a JSON Schema map, keys keyword or string) as a `JsonSchemaElement`."
|
||||||
|
^JsonSchemaElement [schema]
|
||||||
|
(if-not (map? schema)
|
||||||
|
(raw schema)
|
||||||
|
(let [type (some-> (field schema :type) name str/lower-case)
|
||||||
|
enum-values (field schema :enum)]
|
||||||
|
(cond
|
||||||
|
(and (seq enum-values) (or (nil? type) (= "string" type))) (enum-schema schema enum-values)
|
||||||
|
(= "object" type) (object-schema schema)
|
||||||
|
(= "array" type) (array-schema schema)
|
||||||
|
(= "string" type) (simple-schema #(JsonStringSchema/builder) schema)
|
||||||
|
(= "integer" type) (simple-schema #(JsonIntegerSchema/builder) schema)
|
||||||
|
(= "number" type) (simple-schema #(JsonNumberSchema/builder) schema)
|
||||||
|
(= "boolean" type) (simple-schema #(JsonBooleanSchema/builder) schema)
|
||||||
|
:else (raw schema)))))
|
||||||
|
|
||||||
|
(defn tool-parameters
|
||||||
|
"A tool's MCP-style `inputSchema` (already validated as an object schema, see tbd.chat.schema/Tool)
|
||||||
|
as the `JsonObjectSchema` a `ToolSpecification` needs."
|
||||||
|
^JsonObjectSchema [input-schema]
|
||||||
|
(object-schema input-schema))
|
||||||
@@ -10,15 +10,13 @@
|
|||||||
"Cookie-backed sessions (encrypted and signed, so nothing is stored server-side). Lax, not
|
"Cookie-backed sessions (encrypted and signed, so nothing is stored server-side). Lax, not
|
||||||
Strict: the OAuth callback is a cross-site redirect from Gitea and must still carry the
|
Strict: the OAuth callback is a cross-site redirect from Gitea and must still carry the
|
||||||
cookie that holds the login `state`."
|
cookie that holds the login `state`."
|
||||||
[config]
|
[handler config]
|
||||||
(let [store (cookie-store {:key (auth/session-key (:session-secret config))})]
|
(wrap-session handler {:store (cookie-store {:key (auth/session-key (:session-secret config))})
|
||||||
(fn [handler]
|
:cookie-name "tbd-session"
|
||||||
(wrap-session handler {:store store
|
:cookie-attrs {:http-only true
|
||||||
:cookie-name "tbd-session"
|
:same-site :lax
|
||||||
:cookie-attrs {:http-only true
|
:secure (:secure-cookies? config)
|
||||||
:same-site :lax
|
:max-age (quot session-ttl-ms 1000)}}))
|
||||||
:secure (:secure-cookies? config)
|
|
||||||
:max-age (quot session-ttl-ms 1000)}}))))
|
|
||||||
|
|
||||||
(defn current-user
|
(defn current-user
|
||||||
"The logged-in user from the session, or nil when there is none or it has expired. The
|
"The logged-in user from the session, or nil when there is none or it has expired. The
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
(ns tbd.chat.routes
|
||||||
|
"/api/chat: an AG-UI (Agent-User Interaction Protocol, https://docs.ag-ui.com) run. A caller
|
||||||
|
POSTs a RunAgentInput (tbd.chat.schema/RunAgentInput) and gets the agent's reply streamed back
|
||||||
|
as AG-UI's server-sent events: RUN_STARTED, then TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_*
|
||||||
|
as the model generates, then RUN_FINISHED or RUN_ERROR. The agent itself (its system prompt,
|
||||||
|
the LLM call, tool-call assembly) is tbd.agent.core; this ns only translates its internal event
|
||||||
|
shape ({:thinking}/{:content}/{:tool-calls}/{:done}/{:error}, see tbd.agent.core's ns docstring)
|
||||||
|
to and from AG-UI's wire format.
|
||||||
|
|
||||||
|
AG-UI's frontend-tool model — the agent asks for a call, the browser (tbd.ui.webmcp) runs it and
|
||||||
|
sends the result back as a `tool` message on the next run — is exactly the loop tbd.ui.events
|
||||||
|
already drove against the old bespoke SSE shape; nothing about that loop changes here."
|
||||||
|
(:require [muuntaja.core :as m]
|
||||||
|
[ring.core.protocols :as ring-protocols]
|
||||||
|
[tbd.agent.core :as agent]
|
||||||
|
[tbd.chat.schema :as schema]
|
||||||
|
[tbd.http :refer [respond]])
|
||||||
|
(:import (java.io IOException OutputStream)
|
||||||
|
(java.nio.charset StandardCharsets)))
|
||||||
|
|
||||||
|
(def ^:private error-status {:unavailable 503 :upstream 502})
|
||||||
|
|
||||||
|
(defn- sse-event
|
||||||
|
"One server-sent event: `event` (an AG-UI BaseEvent, a plain map with a `:type`) as JSON, on a
|
||||||
|
single line."
|
||||||
|
^bytes [event]
|
||||||
|
(.getBytes (str "data: " (slurp (m/encode "application/json" event)) "\n\n") StandardCharsets/UTF_8))
|
||||||
|
|
||||||
|
;; --- Internal pump events -> AG-UI events ---
|
||||||
|
|
||||||
|
(defn- open-reasoning [{:keys [reasoning-id] :as state}]
|
||||||
|
(if (:reasoning-open? state)
|
||||||
|
[state []]
|
||||||
|
[(assoc state :reasoning-open? true)
|
||||||
|
[{:type "REASONING_START" :messageId reasoning-id}
|
||||||
|
{:type "REASONING_MESSAGE_START" :messageId reasoning-id :role "reasoning"}]]))
|
||||||
|
|
||||||
|
(defn- close-reasoning [{:keys [reasoning-id] :as state}]
|
||||||
|
(if-not (:reasoning-open? state)
|
||||||
|
[state []]
|
||||||
|
[(assoc state :reasoning-open? false)
|
||||||
|
[{:type "REASONING_MESSAGE_END" :messageId reasoning-id}
|
||||||
|
{:type "REASONING_END" :messageId reasoning-id}]]))
|
||||||
|
|
||||||
|
(defn- open-text [{:keys [text-id] :as state}]
|
||||||
|
(if (:text-open? state)
|
||||||
|
[state []]
|
||||||
|
[(assoc state :text-open? true)
|
||||||
|
[{:type "TEXT_MESSAGE_START" :messageId text-id :role "assistant"}]]))
|
||||||
|
|
||||||
|
(defn- close-text [{:keys [text-id] :as state}]
|
||||||
|
(if-not (:text-open? state)
|
||||||
|
[state []]
|
||||||
|
[(assoc state :text-open? false)
|
||||||
|
[{:type "TEXT_MESSAGE_END" :messageId text-id}]]))
|
||||||
|
|
||||||
|
(defn- close-all [state]
|
||||||
|
(let [[state text-events] (close-text state)
|
||||||
|
[state reasoning-events] (close-reasoning state)]
|
||||||
|
[state (into text-events reasoning-events)]))
|
||||||
|
|
||||||
|
(defn- tool-call-events [{:keys [id name arguments]}]
|
||||||
|
[{:type "TOOL_CALL_START" :toolCallId id :toolCallName name}
|
||||||
|
{:type "TOOL_CALL_ARGS" :toolCallId id :delta (str arguments)}
|
||||||
|
{:type "TOOL_CALL_END" :toolCallId id}])
|
||||||
|
|
||||||
|
(defn- ag-ui-events
|
||||||
|
"[state' events]: `state` ({:reasoning-id :text-id :reasoning-open? :text-open?}) and the AG-UI
|
||||||
|
events `event` (tbd.agent.core's internal shape) translates to, opening/closing the
|
||||||
|
reasoning/text message streams as the model switches between them."
|
||||||
|
[state {:keys [thinking content tool-calls done error]} thread-id run-id]
|
||||||
|
(cond
|
||||||
|
thinking (let [[state closed] (close-text state)
|
||||||
|
[state opened] (open-reasoning state)]
|
||||||
|
[state (conj (into closed opened)
|
||||||
|
{:type "REASONING_MESSAGE_CONTENT" :messageId (:reasoning-id state) :delta thinking})])
|
||||||
|
|
||||||
|
content (let [[state closed] (close-reasoning state)
|
||||||
|
[state opened] (open-text state)]
|
||||||
|
[state (conj (into closed opened)
|
||||||
|
{:type "TEXT_MESSAGE_CONTENT" :messageId (:text-id state) :delta content})])
|
||||||
|
|
||||||
|
tool-calls (let [[state closing] (close-all state)]
|
||||||
|
[state (into closing (mapcat tool-call-events) tool-calls)])
|
||||||
|
|
||||||
|
done (let [[state closing] (close-all state)]
|
||||||
|
[state (conj closing {:type "RUN_FINISHED" :threadId thread-id :runId run-id})])
|
||||||
|
|
||||||
|
error (let [[state closing] (close-all state)]
|
||||||
|
[state (conj closing {:type "RUN_ERROR" :message error})])
|
||||||
|
|
||||||
|
:else [state []]))
|
||||||
|
|
||||||
|
(defn- event-stream
|
||||||
|
"A response body that writes what `pump` (see tbd.agent.core/start) forwards, translated to AG-UI
|
||||||
|
events, flushing after every one so they arrive as they are generated. A browser that goes away
|
||||||
|
stops it, and the LLM's request with it."
|
||||||
|
[pump thread-id run-id]
|
||||||
|
(reify ring-protocols/StreamableResponseBody
|
||||||
|
(write-body-to-stream [_ _ out]
|
||||||
|
(let [state (atom {:reasoning-id (str (random-uuid)) :text-id (str (random-uuid))
|
||||||
|
:reasoning-open? false :text-open? false})
|
||||||
|
write! (fn [event] (.write ^OutputStream out (sse-event event)) (.flush ^OutputStream out))]
|
||||||
|
(try
|
||||||
|
(write! {:type "RUN_STARTED" :threadId thread-id :runId run-id})
|
||||||
|
(pump (fn [internal-event]
|
||||||
|
(let [[state' events] (ag-ui-events @state internal-event thread-id run-id)]
|
||||||
|
(reset! state state')
|
||||||
|
(run! write! events))))
|
||||||
|
(catch IOException _ nil))))))
|
||||||
|
|
||||||
|
(defn- chat-handler [config]
|
||||||
|
(fn [{{{:keys [messages tools threadId runId] :as input} :body} :parameters}]
|
||||||
|
(let [thread-id (or threadId (str (random-uuid)))
|
||||||
|
run-id (or runId (str (random-uuid)))
|
||||||
|
system-text (schema/system-text input)
|
||||||
|
internal-messages (mapv schema/->internal-message messages)
|
||||||
|
internal-tools (mapv schema/->internal-tool tools)
|
||||||
|
{:keys [ok error kind]} (agent/start config system-text internal-messages internal-tools)]
|
||||||
|
(if error
|
||||||
|
(respond (error-status kind) {:error error})
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "text/event-stream; charset=utf-8"
|
||||||
|
"Cache-Control" "no-cache"
|
||||||
|
;; nginx would otherwise hold the events back until the reply is over
|
||||||
|
"X-Accel-Buffering" "no"}
|
||||||
|
:body (event-stream ok thread-id run-id)}))))
|
||||||
|
|
||||||
|
(defn routes
|
||||||
|
"`config` is the agent config (see tbd.agent.core/env-config). Only logged-in users may chat:
|
||||||
|
every message costs the LLM server's time."
|
||||||
|
[config]
|
||||||
|
["/api/chat"
|
||||||
|
{:post {:tags ["chat"]
|
||||||
|
:login? true
|
||||||
|
:summary "An AG-UI (https://docs.ag-ui.com) run: POST a RunAgentInput (the conversation so far, tools on offer, and `context` — the page the user is on travels as {\"description\":\"page\",\"value\":\"...\"}); the reply streams back as AG-UI server-sent events (RUN_STARTED, TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_* as the model generates, then RUN_FINISHED or RUN_ERROR). Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if the agent isn't configured"
|
||||||
|
:parameters {:body schema/RunAgentInput}
|
||||||
|
:handler (chat-handler config)}}])
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
(ns tbd.chat.schema
|
||||||
|
"The shape of /api/chat's request, as Malli schemas, coerced by tbd.chat.routes: an AG-UI
|
||||||
|
RunAgentInput (https://docs.ag-ui.com), the standard wire format for an agent↔frontend run. The
|
||||||
|
limits are what keeps the endpoint from being a free pipe to the LLM: a conversation is bounded
|
||||||
|
in length, and the UI (tbd.ui.events) sends no more than `max-messages` of it.
|
||||||
|
|
||||||
|
The conversation can hold the model's use of the page's tools (see tbd.ui.model-context): an
|
||||||
|
assistant message that asks for calls (`:toolCalls`), followed by one \"tool\" role message with
|
||||||
|
the result of each. Those are the browser's to say, so they are as bounded as the rest. Which
|
||||||
|
page the user is on travels as a `context` entry ({:description \"page\" :value \"todos\"}) rather
|
||||||
|
than a dedicated field — AG-UI's place for ambient, non-conversation information."
|
||||||
|
(:require [tbd.agent.instructions :as instructions]))
|
||||||
|
|
||||||
|
(def max-messages 40)
|
||||||
|
(def max-message-length 8000)
|
||||||
|
|
||||||
|
(def max-tools 32)
|
||||||
|
(def max-tool-calls 16)
|
||||||
|
|
||||||
|
(def ^:private max-tool-schema-length
|
||||||
|
"How long a tool's metadata may be, printed: room for a tool of a dozen parameters."
|
||||||
|
5000)
|
||||||
|
|
||||||
|
(defn- small? [tool]
|
||||||
|
(<= (count (pr-str tool)) max-tool-schema-length))
|
||||||
|
|
||||||
|
(def Tool
|
||||||
|
"AG-UI's Tool: {name, description, parameters}. `parameters` is JSON Schema for the arguments,
|
||||||
|
which is the LLM's to read, so it is any (small) object."
|
||||||
|
[:and
|
||||||
|
[:map
|
||||||
|
[:name [:re #"^[a-zA-Z0-9_-]{1,64}$"]]
|
||||||
|
[:description {:optional true} [:string {:max 2000}]]
|
||||||
|
[:parameters {:optional true} [:map-of :keyword :any]]]
|
||||||
|
[:fn small?]])
|
||||||
|
|
||||||
|
(def ^:private Id [:string {:min 1 :max 200}])
|
||||||
|
|
||||||
|
(def FunctionCall
|
||||||
|
[:map
|
||||||
|
[:name [:string {:min 1 :max 200}]]
|
||||||
|
[:arguments [:string {:max 100000}]]])
|
||||||
|
|
||||||
|
(def ToolCall
|
||||||
|
"AG-UI's ToolCall: a call the model asked for. Its name and arguments are the model's own
|
||||||
|
words: nothing says they are a tool of the page, or JSON, so they are only bounded, generously
|
||||||
|
(a conversation that the server refused would be stuck: the UI sends it again with the next
|
||||||
|
message)."
|
||||||
|
[:map
|
||||||
|
[:id Id]
|
||||||
|
[:type [:= "function"]]
|
||||||
|
[:function FunctionCall]])
|
||||||
|
|
||||||
|
(def Message
|
||||||
|
"AG-UI's Message, narrowed to the roles this endpoint accepts from a caller: user, assistant
|
||||||
|
(optionally with `toolCalls`) and tool (answering one). `id` identifies the message within the
|
||||||
|
conversation (AG-UI requires it); this endpoint doesn't need it to be unique or stable, only
|
||||||
|
present."
|
||||||
|
[:multi {:dispatch :role}
|
||||||
|
["user" [:map
|
||||||
|
[:id Id]
|
||||||
|
[:role [:= "user"]]
|
||||||
|
[:content [:string {:min 1 :max max-message-length}]]]]
|
||||||
|
["assistant" [:and
|
||||||
|
[:map
|
||||||
|
[:id Id]
|
||||||
|
[:role [:= "assistant"]]
|
||||||
|
[:content {:optional true} [:string {:max max-message-length}]]
|
||||||
|
[:toolCalls {:optional true} [:vector {:max max-tool-calls} ToolCall]]]
|
||||||
|
[:fn (fn [{:keys [content toolCalls]}] (or (seq content) (seq toolCalls)))]]]
|
||||||
|
["tool" [:map
|
||||||
|
[:id Id]
|
||||||
|
[:role [:= "tool"]]
|
||||||
|
[:toolCallId Id]
|
||||||
|
[:content [:string {:min 1 :max max-message-length}]]]]])
|
||||||
|
|
||||||
|
(def Context
|
||||||
|
"AG-UI's Context: a named piece of ambient information for the run. The page the user is on is
|
||||||
|
sent as {:description \"page\" :value \"<page id>\"}."
|
||||||
|
[:map
|
||||||
|
[:description [:string {:max 200}]]
|
||||||
|
[:value [:string {:max 2000}]]])
|
||||||
|
|
||||||
|
(def RunAgentInput
|
||||||
|
"AG-UI's RunAgentInput: the conversation so far, oldest first (`messages`), the tools on offer
|
||||||
|
for this run (`tools`), and ambient information (`context`, which is where `page` travels).
|
||||||
|
`threadId`/`runId` identify the run to the client; this server is stateless between turns (the
|
||||||
|
browser resends the whole conversation each time, see tbd.ui.events), so it only echoes them
|
||||||
|
back, never reads them."
|
||||||
|
[:map
|
||||||
|
[:threadId {:optional true} [:string {:max 200}]]
|
||||||
|
[:runId {:optional true} [:string {:max 200}]]
|
||||||
|
[:messages [:vector {:min 1 :max max-messages} Message]]
|
||||||
|
[:tools {:optional true} [:vector {:max max-tools} Tool]]
|
||||||
|
[:context {:optional true} [:vector {:max 8} Context]]])
|
||||||
|
|
||||||
|
(defn page-of
|
||||||
|
"The `page` context entry's value, or nil."
|
||||||
|
[context]
|
||||||
|
(some #(when (= "page" (:description %)) (:value %)) context))
|
||||||
|
|
||||||
|
(defn ->internal-tool
|
||||||
|
"An AG-UI Tool as the MCP-style {:name :description :inputSchema} tbd.agent.core and
|
||||||
|
tbd.agent.json-schema expect."
|
||||||
|
[{:keys [name description parameters]}]
|
||||||
|
(cond-> {:name name :inputSchema (or parameters {})}
|
||||||
|
description (assoc :description description)))
|
||||||
|
|
||||||
|
(defn ->internal-message
|
||||||
|
"An AG-UI Message as tbd.agent.core's internal {:role :content :tool-calls :tool-call-id} (see
|
||||||
|
tbd.agent.core/llm-message)."
|
||||||
|
[{:keys [role content toolCalls toolCallId]}]
|
||||||
|
(cond-> {:role role :content content}
|
||||||
|
(seq toolCalls) (assoc :tool-calls
|
||||||
|
(mapv (fn [{:keys [id function]}]
|
||||||
|
{:id id :name (:name function) :arguments (:arguments function)})
|
||||||
|
toolCalls))
|
||||||
|
toolCallId (assoc :tool-call-id toolCallId)))
|
||||||
|
|
||||||
|
(defn system-text
|
||||||
|
"The agent's own system prompt for this run (see tbd.agent.instructions), never the caller's to
|
||||||
|
supply: only `page` (from `context`) and `tools` shape it."
|
||||||
|
[{:keys [tools context]}]
|
||||||
|
(instructions/chat-system-message (page-of context) tools))
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
(ns tbd.chat.trace
|
||||||
|
"Observability for the chatbot's LLM turns: what the model was asked, what it did with it, how long
|
||||||
|
it took, and whether it looks stuck. No Ring and no LLM here; tbd.agent.core (the chatbot's agent)
|
||||||
|
and tbd.agent.a2a (the A2A server) both call it.
|
||||||
|
|
||||||
|
One request to /api/chat, or one message to the A2A server, is one turn of the model. The loop that
|
||||||
|
runs the chatbot's tools lives in the browser
|
||||||
|
(tbd.ui.events): a tool's result comes back as a new request holding the conversation so far, so the
|
||||||
|
server keeps nothing between turns. A stuck agent shows up in two ways, and both are watched:
|
||||||
|
|
||||||
|
- within a turn, as reasoning that never ends (`:long-reasoning`) or that says the same sentence
|
||||||
|
again and again (`:repeated-thinking`);
|
||||||
|
- across turns, as the same tool call made over and over (`:repeated-call`), read off the
|
||||||
|
conversation each request carries.
|
||||||
|
|
||||||
|
Every turn is one `tracer`: `begin!` when the request goes out (logs `:turn-start`), `observe!` for
|
||||||
|
each event of the reply (logs a `:loop-suspect` when one of the above trips), `finish!` when it ends
|
||||||
|
(logs `:turn-end`, with the outcome, timings, sizes and flags). A tracer is for one thread, the one
|
||||||
|
that reads the reply.
|
||||||
|
|
||||||
|
Turns are correlated by `req` (one per turn) and `run` (a hash of the user's last message, the same
|
||||||
|
for every turn of the tool loop it started), with `step` counting the turns of the run from 0.
|
||||||
|
|
||||||
|
Entries are maps that go to a `sink`. `sink` prints each as one `[tbd] chat <event> k=v ...` line and,
|
||||||
|
if given a file, appends it there as JSON. Only the file has the text of the reasoning and the reply
|
||||||
|
(`:reasoning`, `:reply`) and the run's messages (`:input`): they are what you read to see why a
|
||||||
|
model is looping, and they are the user's words, so they are off unless a file is asked for
|
||||||
|
(CHAT_TRACE_FILE, see tbd.agent.core/env-config)."
|
||||||
|
(:require [clojure.java.io :as io]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[muuntaja.core :as m])
|
||||||
|
(:import (java.time Instant)))
|
||||||
|
|
||||||
|
(def reasoning-warn-chars
|
||||||
|
"Reasoning this long, without an answer or a call yet, is worth a look: a `:long-reasoning` entry
|
||||||
|
at this many characters, and again at each doubling."
|
||||||
|
8000)
|
||||||
|
|
||||||
|
(def ^:private repeat-min-length
|
||||||
|
"Shorter sentences (\"Let me check.\") repeat in any reasoning; only longer ones say anything."
|
||||||
|
20)
|
||||||
|
|
||||||
|
(def ^:private repeat-warn-count
|
||||||
|
"A sentence of the reasoning said this many times is a `:repeated-thinking`; and again at each doubling."
|
||||||
|
4)
|
||||||
|
|
||||||
|
(def ^:private repeated-call-warn-count
|
||||||
|
"The same tool called with the same arguments this many times in one run is a `:repeated-call`."
|
||||||
|
3)
|
||||||
|
|
||||||
|
(def ^:private max-distinct-sentences 5000)
|
||||||
|
(def ^:private max-pending-chars 1000)
|
||||||
|
(def ^:private max-args-length 500)
|
||||||
|
(def ^:private sample-length 160)
|
||||||
|
|
||||||
|
(defn- now-ms [] (quot (System/nanoTime) 1000000))
|
||||||
|
|
||||||
|
(defn- truncate [s n]
|
||||||
|
(let [s (str s)]
|
||||||
|
(if (> (count s) n) (str (subs s 0 n) "…") s)))
|
||||||
|
|
||||||
|
;; --- Reading the conversation ---
|
||||||
|
|
||||||
|
(defn run-summary
|
||||||
|
"What the conversation `messages` (see tbd.chat.schema/Message) says about the tool loop it is in.
|
||||||
|
A run starts at the user's last message: `:run` is a short hash of it (the same for every turn of
|
||||||
|
the run, which is what ties their log lines together), `:step` how many assistant messages have
|
||||||
|
answered it so far (0 for the first turn), `:run-calls` how many tool calls those made,
|
||||||
|
`:top-call` {:tool :args :count} the call made most often (nil if none), and `:input` the messages
|
||||||
|
of the run, the user's first."
|
||||||
|
[messages]
|
||||||
|
(let [messages (vec messages)
|
||||||
|
user-at (last (keep-indexed (fn [i {:keys [role]}] (when (= "user" role) i)) messages))
|
||||||
|
input (if user-at (subvec messages user-at) messages)
|
||||||
|
answers (filter #(= "assistant" (:role %)) input)
|
||||||
|
calls (into [] (mapcat :tool-calls) answers)
|
||||||
|
[[tool args] n] (when (seq calls)
|
||||||
|
(apply max-key val (frequencies (map (juxt :name :arguments) calls))))]
|
||||||
|
{:run (if user-at (format "%08x" (bit-and 0xffffffff (hash (:content (messages user-at))))) "-")
|
||||||
|
:step (count answers)
|
||||||
|
:run-calls (count calls)
|
||||||
|
:top-call (when tool {:tool tool :args args :count n})
|
||||||
|
:input input}))
|
||||||
|
|
||||||
|
;; --- Watching the reply ---
|
||||||
|
|
||||||
|
(defn- split-sentences
|
||||||
|
"[complete pending]: the sentences of `text` that are finished (a full stop, question or exclamation
|
||||||
|
mark, or a line break, ends one), and the rest, which the next piece of text may go on with."
|
||||||
|
[text]
|
||||||
|
(let [parts (str/split text #"(?<=[.!?])\s+|\n+" -1)
|
||||||
|
complete (pop parts)
|
||||||
|
pending (peek parts)]
|
||||||
|
(if (> (count pending) max-pending-chars)
|
||||||
|
[(conj complete pending) ""]
|
||||||
|
[complete pending])))
|
||||||
|
|
||||||
|
(defn- repeat-milestone?
|
||||||
|
"True at the count that is worth a log line: `repeat-warn-count`, then each doubling of it."
|
||||||
|
[n]
|
||||||
|
(and (>= n repeat-warn-count)
|
||||||
|
(zero? (rem n repeat-warn-count))
|
||||||
|
(let [q (quot n repeat-warn-count)] (zero? (bit-and q (dec q))))))
|
||||||
|
|
||||||
|
(defn- count-sentence
|
||||||
|
"[seen alerts], with `sentence` counted in `seen` ({normalized sentence count}); an alert if it
|
||||||
|
has now been said often enough. Case, spacing and the mark that ends it don't make a sentence
|
||||||
|
another: a model that loops varies those as it likes."
|
||||||
|
[[seen alerts] sentence]
|
||||||
|
(let [k (-> sentence str/trim str/lower-case (str/replace #"\s+" " ") (str/replace #"[.!?]+$" ""))]
|
||||||
|
(if (or (< (count k) repeat-min-length)
|
||||||
|
(and (not (contains? seen k)) (>= (count seen) max-distinct-sentences)))
|
||||||
|
[seen alerts]
|
||||||
|
(let [n (inc (get seen k 0))]
|
||||||
|
[(assoc seen k n)
|
||||||
|
(cond-> alerts
|
||||||
|
(repeat-milestone? n) (conj {:kind :repeated-thinking :count n
|
||||||
|
:text (truncate sentence sample-length)}))]))))
|
||||||
|
|
||||||
|
(defn- think
|
||||||
|
"[state' alerts] after `text` of the model's reasoning."
|
||||||
|
[state text now full?]
|
||||||
|
(let [state (cond-> (-> state
|
||||||
|
(update :thinking-events inc)
|
||||||
|
(update :thinking-chars + (count text))
|
||||||
|
(update :first-thinking-at #(or % now)))
|
||||||
|
full? (update :reasoning conj text))
|
||||||
|
[complete pending] (split-sentences (str (:pending state) text))
|
||||||
|
[seen alerts] (reduce count-sentence [(:seen state) []] complete)
|
||||||
|
chars (:thinking-chars state)
|
||||||
|
long? (>= chars (:next-long-warn state))]
|
||||||
|
[(cond-> (assoc state :pending pending :seen seen)
|
||||||
|
long? (update :next-long-warn * 2))
|
||||||
|
(cond-> alerts
|
||||||
|
long? (conj {:kind :long-reasoning :chars chars :elapsed-ms (- now (:t0 state))}))]))
|
||||||
|
|
||||||
|
(defn- advance
|
||||||
|
"[state' alerts]: `state` after one event of the LLM's reply (the map tbd.agent.core/observe! is
|
||||||
|
called with: any of :content, :thinking, :finish-reason, :usage)."
|
||||||
|
[state {:keys [content thinking call-deltas finish-reason usage]} now full?]
|
||||||
|
(let [state (cond-> (-> state
|
||||||
|
(update :events inc)
|
||||||
|
(update :first-event-at #(or % now)))
|
||||||
|
content (-> (update :content-chars + (count content))
|
||||||
|
(update :first-content-at #(or % now)))
|
||||||
|
(and content full?) (update :reply conj content)
|
||||||
|
(seq call-deltas) (update :first-call-at #(or % now))
|
||||||
|
finish-reason (assoc :finish-reason finish-reason)
|
||||||
|
usage (assoc :usage usage))]
|
||||||
|
(if (seq thinking)
|
||||||
|
(think state thinking now full?)
|
||||||
|
[state []])))
|
||||||
|
|
||||||
|
;; --- Sinks: where entries go ---
|
||||||
|
|
||||||
|
(def ^:private lock (Object.))
|
||||||
|
|
||||||
|
(def ^:private field-order
|
||||||
|
[:req :run :step :kind :outcome :page :model :messages :tools :prompt-chars :run-calls :duration-ms
|
||||||
|
:ttft-ms :think-ms :events :thinking-chars :content-chars :finish-reason :prompt-tokens
|
||||||
|
:completion-tokens :count :chars :elapsed-ms :tool :args :text :calls :flags :error :detail])
|
||||||
|
|
||||||
|
(def ^:private heavy-fields
|
||||||
|
"What only the trace file gets."
|
||||||
|
#{:reasoning :reply :input})
|
||||||
|
|
||||||
|
(defn- render-field [k v]
|
||||||
|
(str (name k) "="
|
||||||
|
(cond
|
||||||
|
(keyword? v) (name v)
|
||||||
|
(= :calls k) (pr-str (str/join "; " (map (fn [{:keys [name args]}] (str name (truncate args 120))) v)))
|
||||||
|
(and (sequential? v) (every? keyword? v)) (str/join "," (map name v))
|
||||||
|
(and (string? v) (re-matches #"[\w./:@-]+" v)) v
|
||||||
|
:else (pr-str v))))
|
||||||
|
|
||||||
|
(defn render
|
||||||
|
"An entry as the line the log gets: `[tbd] chat turn-end req=ab12cd34 outcome=done ...`. Without the
|
||||||
|
heavy fields (see the ns docstring), and without what is nil or empty."
|
||||||
|
[{:keys [event] :as entry}]
|
||||||
|
(let [order (zipmap field-order (range))
|
||||||
|
fields (->> (reduce dissoc entry (cons :event heavy-fields))
|
||||||
|
(remove (fn [[_ v]] (or (nil? v) (and (coll? v) (empty? v)))))
|
||||||
|
(sort-by (fn [[k _]] [(get order k (count field-order)) (name k)])))]
|
||||||
|
(str "[tbd] chat " (name event) (apply str (for [[k v] fields] (str " " (render-field k v)))))))
|
||||||
|
|
||||||
|
(defn- json [data] (slurp (m/encode "application/json" data)))
|
||||||
|
|
||||||
|
(defn sink
|
||||||
|
"A fn that takes entries: prints each with `render`, and appends it to `file`, if there is one, as a
|
||||||
|
line of JSON with a timestamp and all its fields. A file that can't be written to is complained
|
||||||
|
about, and never fails the chat."
|
||||||
|
[file]
|
||||||
|
(when file (io/make-parents (io/file file)))
|
||||||
|
(fn [entry]
|
||||||
|
(locking lock
|
||||||
|
(println (render entry))
|
||||||
|
(when file
|
||||||
|
(try (spit file (str (json (-> (into {} (remove (comp nil? val)) entry)
|
||||||
|
(assoc :ts (str (Instant/now)))))
|
||||||
|
"\n")
|
||||||
|
:append true)
|
||||||
|
(catch Exception e
|
||||||
|
(println "[tbd] chat: can't write the trace file" file (str "(" (.getMessage e) ")"))))))))
|
||||||
|
|
||||||
|
(defn config-for
|
||||||
|
"The trace config (what `begin!` takes) for a trace file, or none if `file` is nil: the entries are
|
||||||
|
logged either way, and the text of the reasoning and the replies goes to the file only."
|
||||||
|
[file]
|
||||||
|
{:sink (sink file) :full-text? (some? file) :file file})
|
||||||
|
|
||||||
|
(def default-config
|
||||||
|
"Log lines only."
|
||||||
|
(config-for nil))
|
||||||
|
|
||||||
|
;; --- Tracing a turn ---
|
||||||
|
|
||||||
|
(defn- log! [{:keys [sink ids]} event fields]
|
||||||
|
(try (sink (merge {:event event} ids fields))
|
||||||
|
(catch Throwable _ nil)))
|
||||||
|
|
||||||
|
(defn begin!
|
||||||
|
"Starts tracing a turn, and logs `:turn-start`; `messages`, `tools` and `page` are the request's, and
|
||||||
|
`model` the LLM's, if the config names one. `trace` is a config of `config-for`, or nil for
|
||||||
|
`default-config`. Returns the tracer."
|
||||||
|
[trace {:keys [messages tools page model]}]
|
||||||
|
(let [{:keys [sink full-text?]} (or trace default-config)
|
||||||
|
{:keys [run step run-calls top-call input]} (run-summary messages)
|
||||||
|
tracer {:sink sink :full-text? full-text?
|
||||||
|
:ids {:req (subs (str (random-uuid)) 0 8) :run run :step step}
|
||||||
|
:state (atom {:t0 (now-ms) :events 0 :thinking-events 0 :thinking-chars 0
|
||||||
|
:content-chars 0 :next-long-warn reasoning-warn-chars
|
||||||
|
:seen {} :pending "" :reasoning [] :reply [] :flags #{}})}]
|
||||||
|
(log! tracer :turn-start
|
||||||
|
(cond-> {:page page :model model :messages (count messages) :tools (count tools)
|
||||||
|
:prompt-chars (transduce (map #(count (:content %))) + 0 messages)
|
||||||
|
:run-calls run-calls}
|
||||||
|
full-text? (assoc :input input)))
|
||||||
|
(when (and top-call (>= (:count top-call) repeated-call-warn-count))
|
||||||
|
(log! tracer :loop-suspect {:kind :repeated-call :tool (:tool top-call) :count (:count top-call)
|
||||||
|
:args (truncate (:args top-call) sample-length)}))
|
||||||
|
tracer))
|
||||||
|
|
||||||
|
(defn observe!
|
||||||
|
"Takes one event of the LLM's reply (see tbd.agent.core, whose pump calls this: :content, :thinking,
|
||||||
|
or, at the end of a turn, :finish-reason and :usage), logging a `:loop-suspect` if the reasoning has
|
||||||
|
gone on for long, or is repeating itself."
|
||||||
|
[{:keys [state full-text?] :as tracer} event]
|
||||||
|
(let [[state' alerts] (advance @state event (now-ms) full-text?)]
|
||||||
|
(reset! state (update state' :flags into (map :kind) alerts))
|
||||||
|
(doseq [alert alerts]
|
||||||
|
(log! tracer :loop-suspect alert))))
|
||||||
|
|
||||||
|
(defn finish!
|
||||||
|
"Ends the turn, logging `:turn-end`; only the first call does anything, so it is safe to call
|
||||||
|
again in a `finally`. `outcome` is a map: {:outcome :done | :tool-calls | :error | :upstream-error
|
||||||
|
| :aborted, :calls [{:name :arguments}] for :tool-calls, :error text and :detail text for the
|
||||||
|
failures}. :aborted is a turn that just stopped (the client went away, or something threw)."
|
||||||
|
[{:keys [state full-text?] :as tracer} {:keys [outcome calls error detail]}]
|
||||||
|
(let [s @state]
|
||||||
|
(when-not (:finished? s)
|
||||||
|
(swap! state assoc :finished? true)
|
||||||
|
(let [now (now-ms)
|
||||||
|
{:keys [t0 first-event-at first-thinking-at first-content-at first-call-at usage]} s
|
||||||
|
answered-at (or first-content-at first-call-at)
|
||||||
|
flags (cond-> (:flags s) (= "length" (:finish-reason s)) (conj :truncated))]
|
||||||
|
(log! tracer :turn-end
|
||||||
|
(cond-> {:outcome outcome
|
||||||
|
:duration-ms (- now t0)
|
||||||
|
:ttft-ms (some-> first-event-at (- t0))
|
||||||
|
:think-ms (when first-thinking-at (- (or answered-at now) first-thinking-at))
|
||||||
|
:events (:events s)
|
||||||
|
:thinking-chars (:thinking-chars s)
|
||||||
|
:content-chars (:content-chars s)
|
||||||
|
:finish-reason (:finish-reason s)
|
||||||
|
:prompt-tokens (:prompt_tokens usage)
|
||||||
|
:completion-tokens (:completion_tokens usage)
|
||||||
|
:calls (mapv (fn [{:keys [name arguments]}]
|
||||||
|
{:name name :args (truncate arguments max-args-length)})
|
||||||
|
calls)
|
||||||
|
:error error
|
||||||
|
:detail detail
|
||||||
|
:flags (vec (sort flags))}
|
||||||
|
full-text? (assoc :reasoning (str/join (:reasoning s))
|
||||||
|
:reply (str/join (:reply s)))))))))
|
||||||
+19
-10
@@ -14,9 +14,12 @@
|
|||||||
[reitit.ring.middleware.parameters :as parameters]
|
[reitit.ring.middleware.parameters :as parameters]
|
||||||
[muuntaja.core :as m]
|
[muuntaja.core :as m]
|
||||||
[ring.util.response :as response]
|
[ring.util.response :as response]
|
||||||
|
[tbd.agent.a2a :as a2a]
|
||||||
|
[tbd.agent.core :as agent]
|
||||||
[tbd.auth.core :as auth]
|
[tbd.auth.core :as auth]
|
||||||
[tbd.auth.middleware :as auth-mw]
|
[tbd.auth.middleware :as auth-mw]
|
||||||
[tbd.auth.routes :as auth-routes]
|
[tbd.auth.routes :as auth-routes]
|
||||||
|
[tbd.chat.routes :as chat-routes]
|
||||||
[tbd.health.routes :as health]
|
[tbd.health.routes :as health]
|
||||||
[tbd.http :as http]
|
[tbd.http :as http]
|
||||||
[tbd.math.routes :as math]
|
[tbd.math.routes :as math]
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
[tbd.nrepl.routes :as nrepl-routes]
|
[tbd.nrepl.routes :as nrepl-routes]
|
||||||
[tbd.oauth.db :as oauth-db]
|
[tbd.oauth.db :as oauth-db]
|
||||||
[tbd.oauth.routes :as oauth-routes]
|
[tbd.oauth.routes :as oauth-routes]
|
||||||
|
[tbd.sqlite :as sqlite]
|
||||||
[tbd.todo.db :as todo-db]
|
[tbd.todo.db :as todo-db]
|
||||||
[tbd.todo.routes :as todo-routes]))
|
[tbd.todo.routes :as todo-routes]))
|
||||||
|
|
||||||
@@ -68,10 +72,9 @@
|
|||||||
|
|
||||||
;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes`
|
;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes`
|
||||||
;; namespace that returns its own route subtree; they're concatenated here.
|
;; namespace that returns its own route subtree; they're concatenated here.
|
||||||
(defn make-router [{:keys [auth nrepl todo oauth webmcp]}]
|
(defn make-router [{:keys [auth nrepl db webmcp chat]}]
|
||||||
(let [oauth-ds (delay (oauth-db/open (:db-path oauth)))
|
;; One database for every group's tables, opened (and migrated) on first use, not when the app loads.
|
||||||
;; Opened (and migrated) on first use, not when the app loads.
|
(let [ds (delay (sqlite/open (:db-path db) todo-db/migrate! oauth-db/migrate!))]
|
||||||
todo-ds (delay (todo-db/open (:db-path todo)))]
|
|
||||||
(ring/router
|
(ring/router
|
||||||
[["/swagger.json"
|
[["/swagger.json"
|
||||||
;; The spec is JSON, whatever the API default is (Swagger UI can't read EDN).
|
;; The spec is JSON, whatever the API default is (Swagger UI can't read EDN).
|
||||||
@@ -83,11 +86,13 @@
|
|||||||
:handler (index-handler webmcp)}}]
|
:handler (index-handler webmcp)}}]
|
||||||
(health/routes)
|
(health/routes)
|
||||||
(math/routes)
|
(math/routes)
|
||||||
(mcp-routes/routes auth oauth-ds todo-ds)
|
(mcp-routes/routes auth ds)
|
||||||
(oauth-routes/routes auth oauth-ds)
|
(a2a/routes auth ds chat)
|
||||||
|
(oauth-routes/routes auth ds)
|
||||||
(auth-routes/routes auth)
|
(auth-routes/routes auth)
|
||||||
|
(chat-routes/routes chat)
|
||||||
(nrepl-routes/routes nrepl)
|
(nrepl-routes/routes nrepl)
|
||||||
(todo-routes/routes todo-ds)]
|
(todo-routes/routes ds)]
|
||||||
{:data {:muuntaja http/muuntaja
|
{:data {:muuntaja http/muuntaja
|
||||||
:coercion malli/coercion
|
:coercion malli/coercion
|
||||||
;; Handlers (and the auth/error middleware) return plain data; the muuntaja
|
;; Handlers (and the auth/error middleware) return plain data; the muuntaja
|
||||||
@@ -115,13 +120,13 @@
|
|||||||
(ring/create-default-handler)))
|
(ring/create-default-handler)))
|
||||||
wrap-head
|
wrap-head
|
||||||
http/wrap-browser-viewable-edn
|
http/wrap-browser-viewable-edn
|
||||||
((auth-mw/session-middleware auth))))
|
(auth-mw/session-middleware auth)))
|
||||||
|
|
||||||
(def config
|
(def config
|
||||||
{:auth (auth/env-config)
|
{:auth (auth/env-config)
|
||||||
:nrepl (nrepl/env-config)
|
:nrepl (nrepl/env-config)
|
||||||
:todo (todo-db/env-config)
|
:chat (agent/env-config)
|
||||||
:oauth (oauth-db/env-config)
|
:db (sqlite/env-config)
|
||||||
:webmcp {:origin-trial-token (some-> (System/getenv "WEBMCP_ORIGIN_TRIAL_TOKEN") str/trim not-empty)}})
|
:webmcp {:origin-trial-token (some-> (System/getenv "WEBMCP_ORIGIN_TRIAL_TOKEN") str/trim not-empty)}})
|
||||||
|
|
||||||
(def app (make-app config))
|
(def app (make-app config))
|
||||||
@@ -141,6 +146,10 @@
|
|||||||
(println "[tbd] WARNING: ADMIN_USERS is empty; nobody can use the admin API"))
|
(println "[tbd] WARNING: ADMIN_USERS is empty; nobody can use the admin API"))
|
||||||
(when-not (get-in config [:auth :session-secret])
|
(when-not (get-in config [:auth :session-secret])
|
||||||
(println "[tbd] WARNING: SESSION_SECRET is not set; logins won't survive a restart"))
|
(println "[tbd] WARNING: SESSION_SECRET is not set; logins won't survive a restart"))
|
||||||
|
(when-not (agent/configured? (:chat config))
|
||||||
|
(println "[tbd] LMSTUDIO_URL is not set; the chatbot is unavailable"))
|
||||||
|
(when-let [file (get-in config [:chat :trace :file])]
|
||||||
|
(println (str "[tbd] chat: tracing every turn, reasoning and replies included, to " file)))
|
||||||
(try
|
(try
|
||||||
(let [server (jetty/run-jetty (wrap-defaults app api-defaults)
|
(let [server (jetty/run-jetty (wrap-defaults app api-defaults)
|
||||||
{:port port
|
{:port port
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
|
|
||||||
(defn make-handler
|
(defn make-handler
|
||||||
"A Ring handler speaking MCP over Streamable HTTP, for callers with a valid access token
|
"A Ring handler speaking MCP over Streamable HTTP, for callers with a valid access token
|
||||||
(`auth` is the auth config, `ds` a delay of the OAuth database, `todo-ds` one of the todo database). It keeps its MCP sessions in
|
(`auth` is the auth config, `ds` a delay of the app's database). It keeps its MCP sessions in
|
||||||
memory, so each handler is its own server: sessions don't outlive it, and aren't shared between nodes."
|
memory, so each handler is its own server: sessions don't outlive it, and aren't shared between nodes."
|
||||||
[auth ds todo-ds]
|
[auth ds]
|
||||||
(let [{:keys [runtime jsonrpc-handler] :as options}
|
(let [{:keys [runtime jsonrpc-handler] :as options}
|
||||||
(mcp-server/make-server-options
|
(mcp-server/make-server-options
|
||||||
{:info (es/make-info "tbd" "1.0.0" "tbd MCP server")
|
{:info (es/make-info "tbd" "1.0.0" "tbd MCP server")
|
||||||
@@ -29,14 +29,14 @@
|
|||||||
:traffic-logger traffic-logger/nop-traffic-logger})]
|
:traffic-logger traffic-logger/nop-traffic-logger})]
|
||||||
(-> (mcp-http/make-ring-handler runtime jsonrpc-handler options)
|
(-> (mcp-http/make-ring-handler runtime jsonrpc-handler options)
|
||||||
;; What the tools that touch todos need besides the caller (see tbd.mcp.tools).
|
;; What the tools that touch todos need besides the caller (see tbd.mcp.tools).
|
||||||
(wrap-runtime {tools/todo-ds-key todo-ds})
|
(wrap-runtime {tools/ds-key ds})
|
||||||
;; Jetty hands over the body as a stream; plumcp reads the message through `:on-msg`.
|
;; Jetty hands over the body as a stream; plumcp reads the message through `:on-msg`.
|
||||||
mcp-http/wrap-request-body-reader
|
mcp-http/wrap-request-body-reader
|
||||||
;; Outermost, so a request without a token is turned away before its body is read.
|
;; Outermost, so a request without a token is turned away before its body is read.
|
||||||
(oauth-mw/wrap-bearer-auth auth ds))))
|
(oauth-mw/wrap-bearer-auth auth ds))))
|
||||||
|
|
||||||
(defn routes [auth ds todo-ds]
|
(defn routes [auth ds]
|
||||||
(let [handler (make-handler auth ds todo-ds)]
|
(let [handler (make-handler auth ds)]
|
||||||
["/mcp"
|
["/mcp"
|
||||||
;; plumcp does its own content negotiation, JSON parsing and (via the wrapper above) body
|
;; plumcp does its own content negotiation, JSON parsing and (via the wrapper above) body
|
||||||
;; reading, so it must not sit behind the API's muuntaja/coercion middleware chain. ^:replace
|
;; reading, so it must not sit behind the API's muuntaja/coercion middleware chain. ^:replace
|
||||||
|
|||||||
+10
-10
@@ -18,10 +18,10 @@
|
|||||||
[tbd.todo.schema :as todo-schema]
|
[tbd.todo.schema :as todo-schema]
|
||||||
[tbd.todo.service :as todo]))
|
[tbd.todo.service :as todo]))
|
||||||
|
|
||||||
(def todo-ds-key
|
(def ds-key
|
||||||
"Where the todo database (a delay, as for the HTTP routes) is in the request's plumcp runtime
|
"Where the database (a delay, as for the HTTP routes) is in the request's plumcp runtime bag;
|
||||||
bag; tbd.mcp.routes puts it there."
|
tbd.mcp.routes puts it there."
|
||||||
:tbd/todo-ds)
|
:tbd/ds)
|
||||||
|
|
||||||
(defn- current-user
|
(defn- current-user
|
||||||
"Who is calling: {:login :id :admin? :client-id}, the user whose access token the request
|
"Who is calling: {:login :id :admin? :client-id}, the user whose access token the request
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
(if error (throw (ex-info error {})) ok))
|
(if error (throw (ex-info error {})) ok))
|
||||||
|
|
||||||
;; Todos belong to the caller, and tbd.todo.service scopes every operation to them.
|
;; Todos belong to the caller, and tbd.todo.service scopes every operation to them.
|
||||||
(defn- todo-ds [ctx] (ctx-get ctx todo-ds-key))
|
(defn- ds [ctx] (ctx-get ctx ds-key))
|
||||||
|
|
||||||
(def primitives
|
(def primitives
|
||||||
{:tools
|
{:tools
|
||||||
@@ -55,20 +55,20 @@
|
|||||||
(format "%s (Gitea user %d)%s" login id (if admin? ", admin" "")))))
|
(format "%s (Gitea user %d)%s" login id (if admin? ", admin" "")))))
|
||||||
|
|
||||||
(tool "list_todos" "Lists your todos (JSON: id, title, done, created-at, updated-at), oldest first" [:map]
|
(tool "list_todos" "Lists your todos (JSON: id, title, done, created-at, updated-at), oldest first" [:map]
|
||||||
(fn [_ ctx] (->json (ok! (todo/list-todos (todo-ds ctx) (current-user ctx))))))
|
(fn [_ ctx] (->json (ok! (todo/list-todos (ds ctx) (current-user ctx))))))
|
||||||
|
|
||||||
(tool "get_todo" "Gets one of your todos by id (JSON)" todo-schema/IdParam
|
(tool "get_todo" "Gets one of your todos by id (JSON)" todo-schema/IdParam
|
||||||
(fn [{:keys [id]} ctx] (->json (ok! (todo/find-todo (todo-ds ctx) (current-user ctx) id)))))
|
(fn [{:keys [id]} ctx] (->json (ok! (todo/find-todo (ds ctx) (current-user ctx) id)))))
|
||||||
|
|
||||||
(tool "create_todo" "Creates a todo for you and returns it (JSON)" todo-schema/NewTodo
|
(tool "create_todo" "Creates a todo for you and returns it (JSON)" todo-schema/NewTodo
|
||||||
(fn [{:keys [title]} ctx] (->json (ok! (todo/create! (todo-ds ctx) (current-user ctx) title)))))
|
(fn [{:keys [title]} ctx] (->json (ok! (todo/create! (ds ctx) (current-user ctx) title)))))
|
||||||
|
|
||||||
(tool "update_todo" "Changes the title and/or done flag of one of your todos and returns it (JSON)"
|
(tool "update_todo" "Changes the title and/or done flag of one of your todos and returns it (JSON)"
|
||||||
todo-schema/UpdateTodo
|
todo-schema/UpdateTodo
|
||||||
(fn [{:keys [id] :as input} ctx]
|
(fn [{:keys [id] :as input} ctx]
|
||||||
(->json (ok! (todo/update! (todo-ds ctx) (current-user ctx) id (dissoc input :id))))))
|
(->json (ok! (todo/update! (ds ctx) (current-user ctx) id (dissoc input :id))))))
|
||||||
|
|
||||||
(tool "delete_todo" "Deletes one of your todos" todo-schema/IdParam
|
(tool "delete_todo" "Deletes one of your todos" todo-schema/IdParam
|
||||||
(fn [{:keys [id]} ctx]
|
(fn [{:keys [id]} ctx]
|
||||||
(ok! (todo/delete! (todo-ds ctx) (current-user ctx) id))
|
(ok! (todo/delete! (ds ctx) (current-user ctx) id))
|
||||||
"deleted"))]})
|
"deleted"))]})
|
||||||
|
|||||||
+29
-37
@@ -1,23 +1,31 @@
|
|||||||
(ns tbd.oauth.db
|
(ns tbd.oauth.db
|
||||||
"SQLite persistence for the MCP OAuth server, in a local file. Storage only: what is valid, and
|
"SQLite persistence for the MCP OAuth server, in the app's database (see tbd.sqlite). Storage only: what is valid, and
|
||||||
when, lives in tbd.oauth.core and tbd.oauth.routes. Codes and tokens are stored as hashes
|
when, lives in tbd.oauth.core and tbd.oauth.routes. Codes and tokens are stored as hashes
|
||||||
(see tbd.oauth.core/hash-token), so callers pass hashes here, never the secrets themselves."
|
(see tbd.oauth.core/hash-token), so callers pass hashes here, never the secrets themselves."
|
||||||
(:require [clojure.edn :as edn]
|
(:require [clojure.edn :as edn]
|
||||||
[clojure.java.io :as io]
|
|
||||||
[clojure.string :as str]
|
|
||||||
[next.jdbc :as jdbc]
|
[next.jdbc :as jdbc]
|
||||||
[next.jdbc.result-set :as rs]))
|
[next.jdbc.result-set :as rs]
|
||||||
|
[tbd.sqlite :as sqlite]))
|
||||||
|
|
||||||
(def default-path "data/oauth.db")
|
;; A user's approval of a client: the current access and refresh token (hashed), replaced
|
||||||
|
;; together when the refresh token is used. Deleting the row revokes the approval. `id` is a
|
||||||
|
;; UUID (see tbd.sqlite/new-id); `user_id` is the user's id in Gitea, which the app doesn't issue.
|
||||||
|
(def ^:private create-grants
|
||||||
|
"CREATE TABLE IF NOT EXISTS grants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
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)")
|
||||||
|
|
||||||
(defn env-config
|
(defn migrate!
|
||||||
"OAUTH_DB_PATH: where the SQLite file lives (default data/oauth.db; its directory is created)."
|
"Creates the OAuth tables in the database `ds`, or brings them up to date."
|
||||||
([] (env-config (System/getenv)))
|
[ds]
|
||||||
([env]
|
|
||||||
(let [path (get env "OAUTH_DB_PATH")]
|
|
||||||
{:db-path (if (str/blank? path) default-path (str/trim path))})))
|
|
||||||
|
|
||||||
(defn- migrate! [ds]
|
|
||||||
;; Clients register themselves (RFC 7591), so a row here says nothing about who the user is.
|
;; Clients register themselves (RFC 7591), so a row here says nothing about who the user is.
|
||||||
(jdbc/execute! ds ["CREATE TABLE IF NOT EXISTS clients (
|
(jdbc/execute! ds ["CREATE TABLE IF NOT EXISTS clients (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -34,31 +42,15 @@
|
|||||||
code_challenge TEXT NOT NULL,
|
code_challenge TEXT NOT NULL,
|
||||||
scope TEXT NOT NULL,
|
scope TEXT NOT NULL,
|
||||||
expires_at INTEGER NOT NULL)"])
|
expires_at INTEGER NOT NULL)"])
|
||||||
;; A user's approval of a client: the current access and refresh token (hashed), replaced
|
(sqlite/migrate-integer-id! ds "grants" create-grants)
|
||||||
;; together when the refresh token is used. Deleting the row revokes the approval.
|
(jdbc/execute! ds [create-grants])
|
||||||
(jdbc/execute! ds ["CREATE TABLE IF NOT EXISTS 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! ds ["CREATE INDEX IF NOT EXISTS grants_by_client ON grants (client_id)"]))
|
(jdbc/execute! ds ["CREATE INDEX IF NOT EXISTS grants_by_client ON grants (client_id)"]))
|
||||||
|
|
||||||
(defn open
|
(defn open
|
||||||
"A datasource for the database file at `path`, created and migrated if need be; connections
|
"A database with just the OAuth tables, at `path`; the app opens one with every group's tables
|
||||||
and locking work as in tbd.todo.db."
|
(see tbd.core), which this is for tests of the group on its own."
|
||||||
[path]
|
[path]
|
||||||
(when (str/blank? path)
|
(sqlite/open path migrate!))
|
||||||
(throw (ex-info "no OAuth database path configured (set OAUTH_DB_PATH)" {})))
|
|
||||||
(some-> (io/file path) .getAbsoluteFile .getParentFile .mkdirs)
|
|
||||||
(doto (jdbc/get-datasource {:dbtype "sqlite" :dbname path
|
|
||||||
:journal_mode "WAL" :busy_timeout 5000})
|
|
||||||
migrate!))
|
|
||||||
|
|
||||||
(def ^:private opts {:builder-fn rs/as-unqualified-kebab-maps})
|
(def ^:private opts {:builder-fn rs/as-unqualified-kebab-maps})
|
||||||
|
|
||||||
@@ -107,10 +99,10 @@
|
|||||||
(defn insert-grant!
|
(defn insert-grant!
|
||||||
[ds {:keys [client-id user-id login scope access-hash access-expires-at
|
[ds {:keys [client-id user-id login scope access-hash access-expires-at
|
||||||
refresh-hash refresh-expires-at created-at]}]
|
refresh-hash refresh-expires-at created-at]}]
|
||||||
(jdbc/execute! ds ["INSERT INTO grants (client_id, user_id, login, scope, access_hash, access_expires_at,
|
(jdbc/execute! ds ["INSERT INTO grants (id, client_id, user_id, login, scope, access_hash, access_expires_at,
|
||||||
refresh_hash, refresh_expires_at, created_at)
|
refresh_hash, refresh_expires_at, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
client-id user-id login scope access-hash access-expires-at
|
(sqlite/new-id) client-id user-id login scope access-hash access-expires-at
|
||||||
refresh-hash refresh-expires-at created-at]))
|
refresh-hash refresh-expires-at created-at]))
|
||||||
|
|
||||||
(defn find-grant-by-access
|
(defn find-grant-by-access
|
||||||
|
|||||||
@@ -303,7 +303,7 @@
|
|||||||
(token-error "unsupported_grant_type" "grant_type must be authorization_code or refresh_token"))))
|
(token-error "unsupported_grant_type" "grant_type must be authorization_code or refresh_token"))))
|
||||||
|
|
||||||
(defn routes
|
(defn routes
|
||||||
"`ds` is a delay of the datasource (see tbd.oauth.db/open), so the database isn't touched until needed."
|
"`ds` is a delay of the datasource (see tbd.sqlite/open), so the database isn't touched until needed."
|
||||||
[config ds]
|
[config ds]
|
||||||
(let [guarded #(only-when-configured config %)]
|
(let [guarded #(only-when-configured config %)]
|
||||||
["" {:no-doc true
|
["" {:no-doc true
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
(ns tbd.sqlite
|
||||||
|
"The app's one SQLite database, which every group with storage (tbd.todo.db, tbd.oauth.db)
|
||||||
|
keeps its tables in: where it lives, opening it, the ids of the rows the groups own, and
|
||||||
|
moving a table off the integer ids it used to have."
|
||||||
|
(:require [clojure.java.io :as io]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[next.jdbc :as jdbc]
|
||||||
|
[next.jdbc.result-set :as rs]))
|
||||||
|
|
||||||
|
(def default-path "data/tbd.db")
|
||||||
|
|
||||||
|
(defn env-config
|
||||||
|
"DB_PATH: where the SQLite file lives (default data/tbd.db; its directory is created)."
|
||||||
|
([] (env-config (System/getenv)))
|
||||||
|
([env]
|
||||||
|
(let [path (get env "DB_PATH")]
|
||||||
|
{:db-path (if (str/blank? path) default-path (str/trim path))})))
|
||||||
|
|
||||||
|
(defn open
|
||||||
|
"A datasource for the database file at `path`, created if need be and migrated by each of
|
||||||
|
`migrations` (fns of the datasource, each creating and updating a group's own tables). Each
|
||||||
|
query opens its own connection (cheap for a local file); WAL mode plus a busy timeout let
|
||||||
|
concurrent requests wait on each other's writes instead of failing."
|
||||||
|
[path & migrations]
|
||||||
|
(when (str/blank? path)
|
||||||
|
(throw (ex-info "no database path configured (set DB_PATH)" {})))
|
||||||
|
(some-> (io/file path) .getAbsoluteFile .getParentFile .mkdirs)
|
||||||
|
(let [ds (jdbc/get-datasource {:dbtype "sqlite" :dbname path
|
||||||
|
:journal_mode "WAL" :busy_timeout 5000})]
|
||||||
|
(run! #(% ds) migrations)
|
||||||
|
ds))
|
||||||
|
|
||||||
|
(defn new-id
|
||||||
|
"An id for a row this app owns: a random UUID, as lowercase text. Never an integer: an LLM that
|
||||||
|
reads `7` as a todo's id confuses it with a 7 in a title, and a UUID is nothing else."
|
||||||
|
[]
|
||||||
|
(str (random-uuid)))
|
||||||
|
|
||||||
|
(defn- integer-id? [tx table]
|
||||||
|
(boolean
|
||||||
|
(some (fn [{:keys [name type]}] (and (= "id" name) (= "INTEGER" (str/upper-case type))))
|
||||||
|
(jdbc/execute! tx [(str "PRAGMA table_info(" table ")")]
|
||||||
|
{:builder-fn rs/as-unqualified-maps}))))
|
||||||
|
|
||||||
|
(defn migrate-integer-id!
|
||||||
|
"If `table` still has the INTEGER id it was first created with, recreates it with `create-sql`
|
||||||
|
(its current definition, an id TEXT PRIMARY KEY) and gives each row a UUID, keeping the
|
||||||
|
rows' order and everything else in them. Does nothing for a new database or one already
|
||||||
|
migrated. Runs in a transaction, so a failure leaves the old table as it was."
|
||||||
|
[ds table create-sql]
|
||||||
|
(jdbc/with-transaction [tx ds]
|
||||||
|
(when (integer-id? tx table)
|
||||||
|
(let [rows (jdbc/execute! tx [(str "SELECT * FROM " table " ORDER BY id")]
|
||||||
|
{:builder-fn rs/as-unqualified-maps})]
|
||||||
|
(jdbc/execute! tx [(str "DROP TABLE " table)])
|
||||||
|
(jdbc/execute! tx [create-sql])
|
||||||
|
(doseq [row rows
|
||||||
|
:let [row (assoc row :id (new-id))
|
||||||
|
columns (keys row)]]
|
||||||
|
(jdbc/execute! tx (into [(str "INSERT INTO " table " (" (str/join ", " (map name columns)) ")"
|
||||||
|
" VALUES (" (str/join ", " (repeat (count columns) "?")) ")")]
|
||||||
|
(map row columns))))))))
|
||||||
+31
-37
@@ -1,42 +1,34 @@
|
|||||||
(ns tbd.todo.db
|
(ns tbd.todo.db
|
||||||
"SQLite persistence for the todo group, in a local file. Storage only: validation and other
|
"SQLite persistence for the todo group, in the app's database (see tbd.sqlite). Storage only: validation and other
|
||||||
rules live in tbd.todo.core. Every query takes an `owner-id` and filters on it, so one
|
rules live in tbd.todo.core. Every query takes an `owner-id` and filters on it, so one
|
||||||
user's todos can't be read or changed through another user's ids."
|
user's todos can't be read or changed through another user's ids."
|
||||||
(:require [clojure.java.io :as io]
|
(:require [next.jdbc :as jdbc]
|
||||||
[clojure.string :as str]
|
[next.jdbc.result-set :as rs]
|
||||||
[next.jdbc :as jdbc]
|
[tbd.sqlite :as sqlite]))
|
||||||
[next.jdbc.result-set :as rs]))
|
|
||||||
|
|
||||||
(def default-path "data/todo.db")
|
(def ^:private create-todos
|
||||||
|
;; `id` is a UUID (see tbd.sqlite/new-id); `owner_id` is the user's id in Gitea, which the app
|
||||||
|
;; doesn't issue, so it stays the integer Gitea gave.
|
||||||
|
"CREATE TABLE IF NOT EXISTS todos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
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)")
|
||||||
|
|
||||||
(defn env-config
|
(defn migrate!
|
||||||
"TODO_DB_PATH: where the SQLite file lives (default data/todo.db; its directory is created)."
|
"Creates the todo tables in the database `ds`, or brings them up to date."
|
||||||
([] (env-config (System/getenv)))
|
[ds]
|
||||||
([env]
|
(sqlite/migrate-integer-id! ds "todos" create-todos)
|
||||||
(let [path (get env "TODO_DB_PATH")]
|
(jdbc/execute! ds [create-todos])
|
||||||
{:db-path (if (str/blank? path) default-path (str/trim path))})))
|
(jdbc/execute! ds ["CREATE INDEX IF NOT EXISTS todos_by_owner ON todos (owner_id, created_at)"]))
|
||||||
|
|
||||||
(defn- migrate! [ds]
|
|
||||||
(jdbc/execute! ds ["CREATE TABLE IF NOT EXISTS 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! ds ["CREATE INDEX IF NOT EXISTS todos_by_owner ON todos (owner_id, id)"]))
|
|
||||||
|
|
||||||
(defn open
|
(defn open
|
||||||
"A datasource for the database file at `path`, created and migrated if need be. Each query
|
"A database with just the todo tables, at `path`; the app opens one with every group's tables
|
||||||
opens its own connection (cheap for a local file); WAL mode plus a busy timeout let
|
(see tbd.core), which this is for tests of the group on its own."
|
||||||
concurrent requests wait on each other's writes instead of failing."
|
|
||||||
[path]
|
[path]
|
||||||
(when (str/blank? path)
|
(sqlite/open path migrate!))
|
||||||
(throw (ex-info "no todo database path configured (set TODO_DB_PATH)" {})))
|
|
||||||
(some-> (io/file path) .getAbsoluteFile .getParentFile .mkdirs)
|
|
||||||
(doto (jdbc/get-datasource {:dbtype "sqlite" :dbname path
|
|
||||||
:journal_mode "WAL" :busy_timeout 5000})
|
|
||||||
migrate!))
|
|
||||||
|
|
||||||
(def ^:private opts {:builder-fn rs/as-unqualified-kebab-maps})
|
(def ^:private opts {:builder-fn rs/as-unqualified-kebab-maps})
|
||||||
|
|
||||||
@@ -45,17 +37,19 @@
|
|||||||
(some-> row (update :done #(= 1 %))))
|
(some-> row (update :done #(= 1 %))))
|
||||||
|
|
||||||
(defn insert!
|
(defn insert!
|
||||||
"Stores a todo (as built by tbd.todo.core/new-todo) and returns it with its :id."
|
"Stores a todo (as built by tbd.todo.core/new-todo) and returns it with its new :id, a UUID."
|
||||||
[ds {:keys [owner-id title done created-at updated-at]}]
|
[ds {:keys [owner-id title done created-at updated-at]}]
|
||||||
(row->todo
|
(row->todo
|
||||||
(jdbc/execute-one! ds ["INSERT INTO todos (owner_id, title, done, created_at, updated_at)
|
(jdbc/execute-one! ds ["INSERT INTO todos (id, owner_id, title, done, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?) RETURNING *"
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING *"
|
||||||
owner-id title (if done 1 0) created-at updated-at]
|
(sqlite/new-id) owner-id title (if done 1 0) created-at updated-at]
|
||||||
opts)))
|
opts)))
|
||||||
|
|
||||||
(defn list-todos [ds owner-id]
|
(defn list-todos
|
||||||
|
"The owner's todos, oldest first (ids are random, so they say nothing of the order)."
|
||||||
|
[ds owner-id]
|
||||||
(mapv row->todo
|
(mapv row->todo
|
||||||
(jdbc/execute! ds ["SELECT * FROM todos WHERE owner_id = ? ORDER BY id" owner-id] opts)))
|
(jdbc/execute! ds ["SELECT * FROM todos WHERE owner_id = ? ORDER BY created_at, rowid" owner-id] opts)))
|
||||||
|
|
||||||
(defn find-todo
|
(defn find-todo
|
||||||
"The owner's todo with this id, or nil (including when it belongs to someone else)."
|
"The owner's todo with this id, or nil (including when it belongs to someone else)."
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
(reply 200 (fn [_] {:deleted true}) (service/delete! ds user id))))
|
(reply 200 (fn [_] {:deleted true}) (service/delete! ds user id))))
|
||||||
|
|
||||||
(defn routes
|
(defn routes
|
||||||
"`ds` is a delay of the todo database (see tbd.todo.db/open), shared with the MCP tools."
|
"`ds` is a delay of the app's database (see tbd.sqlite/open), shared with the MCP tools."
|
||||||
[ds]
|
[ds]
|
||||||
(let [id-param {:path schema/IdParam}]
|
(let [id-param {:path schema/IdParam}]
|
||||||
["/api/todos"
|
["/api/todos"
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
(:require [malli.util :as mu]
|
(:require [malli.util :as mu]
|
||||||
[tbd.todo.core :as todo]))
|
[tbd.todo.core :as todo]))
|
||||||
|
|
||||||
(def Id [:int {:description "Todo id"}])
|
(def Id
|
||||||
|
"A todo's id: a UUID, as lowercase text (see tbd.sqlite/new-id), kept a string all the way through."
|
||||||
|
[:re {:description "Todo id, a UUID such as 3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b"
|
||||||
|
:error/message "should be a UUID"}
|
||||||
|
#"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"])
|
||||||
|
|
||||||
(def Title
|
(def Title
|
||||||
[:string {:description (format "What needs doing; trimmed, non-blank, at most %d characters"
|
[:string {:description (format "What needs doing; trimmed, non-blank, at most %d characters"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
same on both and are written (and tested) once. A front end only translates: its request into
|
same on both and are written (and tested) once. A front end only translates: its request into
|
||||||
arguments, and the result into a response.
|
arguments, and the result into a response.
|
||||||
|
|
||||||
`ds` is the delay of the todo database the app shares (opened on first use, so a caller who is
|
`ds` is the delay of the database the app shares (opened on first use, so a caller who is
|
||||||
turned away earlier never opens it). `user` is the authenticated caller, {:id ...} at least
|
turned away earlier never opens it). `user` is the authenticated caller, {:id ...} at least
|
||||||
(however they proved who they are: a session or an access token); a todo is only ever reached
|
(however they proved who they are: a session or an access token); a todo is only ever reached
|
||||||
through its owner's id, and someone else's is reported exactly like a missing one.
|
through its owner's id, and someone else's is reported exactly like a missing one.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
(ns tbd.agent.a2a-test
|
||||||
|
(:require [clojure.test :refer [deftest is testing]]
|
||||||
|
[muuntaja.core :as m]
|
||||||
|
[tbd.agent.test-support :as ts]
|
||||||
|
[tbd.auth.routes-test :as t]
|
||||||
|
[tbd.oauth.core :as oauth]
|
||||||
|
[tbd.oauth.db :as db]
|
||||||
|
[tbd.sqlite-test :as sqlite-test])
|
||||||
|
(:import (java.io ByteArrayInputStream)))
|
||||||
|
|
||||||
|
;; A2A sits behind its own transport, like /mcp, so these go through the real app (tbd.core) and
|
||||||
|
;; speak JSON-RPC to /a2a the way an A2A client does, with an OAuth access token.
|
||||||
|
|
||||||
|
(defn- mint-token!
|
||||||
|
[path login id]
|
||||||
|
(let [token (oauth/random-token)
|
||||||
|
expires-at (+ (System/currentTimeMillis) 60000)]
|
||||||
|
(db/insert-grant! (db/open path) {:client-id "test-client" :user-id id :login login :scope "mcp"
|
||||||
|
:access-hash (oauth/hash-token token) :access-expires-at expires-at
|
||||||
|
:refresh-hash (oauth/hash-token (oauth/random-token))
|
||||||
|
:refresh-expires-at expires-at :created-at 1})
|
||||||
|
token))
|
||||||
|
|
||||||
|
(defn- rpc [app token message]
|
||||||
|
(let [body (slurp (m/encode "application/json" message))
|
||||||
|
resp (app {:request-method :post :uri "/a2a"
|
||||||
|
:headers (cond-> {"content-type" "application/json"}
|
||||||
|
token (assoc "authorization" (str "Bearer " token)))
|
||||||
|
:body (ByteArrayInputStream. (.getBytes ^String body))})]
|
||||||
|
(update resp :body #(when % (m/decode "application/json" %)))))
|
||||||
|
|
||||||
|
(defn- app-with [chat & {:keys [db-path] :or {db-path (sqlite-test/temp-db-path)}}]
|
||||||
|
[(t/make-app (t/gitea-stub "alice") :chat chat :db {:db-path db-path}) db-path])
|
||||||
|
|
||||||
|
(defn- send-message [text]
|
||||||
|
{:jsonrpc "2.0" :id 1 :method "message/send"
|
||||||
|
:params {:message {:role "user" :parts [{:kind "text" :text text}] :messageId "m1" :kind "message"}}})
|
||||||
|
|
||||||
|
(deftest agent-card-test
|
||||||
|
(let [[app] (app-with (ts/config (ts/text-model "hi")))
|
||||||
|
resp (app {:request-method :get :uri "/.well-known/agent-card.json"})]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(let [card (m/decode "application/json" (:body resp))]
|
||||||
|
(is (= "tbd" (:name card)))
|
||||||
|
(is (= "http://tbd.test/a2a" (:url card)))
|
||||||
|
(is (false? (get-in card [:capabilities :streaming])))
|
||||||
|
(is (contains? (:securitySchemes card) :bearer))
|
||||||
|
(is (some #(= "add" (:id %)) (:skills card))))))
|
||||||
|
|
||||||
|
(deftest message-send-test
|
||||||
|
(let [[app path] (app-with (ts/config (ts/text-model "Hi there")))
|
||||||
|
token (mint-token! path "alice" 7)
|
||||||
|
resp (rpc app token (send-message "hi"))]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(is (= "2.0" (get-in resp [:body :jsonrpc])))
|
||||||
|
(is (= 1 (get-in resp [:body :id])))
|
||||||
|
(is (= "agent" (get-in resp [:body :result :role])))
|
||||||
|
(is (= "Hi there" (get-in resp [:body :result :parts 0 :text])))))
|
||||||
|
|
||||||
|
(deftest unauthorized-test
|
||||||
|
(let [[app] (app-with (ts/config (ts/text-model "x")))]
|
||||||
|
(is (= 401 (:status (rpc app nil (send-message "hi")))))
|
||||||
|
(is (= 401 (:status (rpc app "not-a-real-token" (send-message "hi")))))))
|
||||||
|
|
||||||
|
(deftest tool-loop-test
|
||||||
|
(testing "the agent's own tool calls are run immediately, server-side, and the loop continues
|
||||||
|
until it answers in text"
|
||||||
|
(let [[app path] (app-with (ts/config (ts/tool-call-model {:id "c1" :name "add" :arguments "{\"x\":1,\"y\":2}"})))
|
||||||
|
token (mint-token! path "alice" 7)]
|
||||||
|
;; tool-call-model always asks for the same call; run-tools! must give up rather than loop forever
|
||||||
|
(is (= 502 (:status (rpc app token (send-message "add 1 and 2"))))))))
|
||||||
|
|
||||||
|
(deftest tool-loop-runs-a-real-tool-test
|
||||||
|
;; A regression test for the tool handlers themselves (create_todo etc. return JSON via
|
||||||
|
;; tbd.agent.a2a/->json): the first turn asks for a call, the second answers from its result.
|
||||||
|
(let [turn (atom 0)
|
||||||
|
first-model (ts/tool-call-model {:id "c1" :name "create_todo" :arguments "{\"title\":\"milk\"}"})
|
||||||
|
second-model (ts/text-model "Added it.")
|
||||||
|
model (ts/model-of
|
||||||
|
(fn [req handler]
|
||||||
|
(if (zero? @turn)
|
||||||
|
(do (swap! turn inc) (.doChat first-model req handler))
|
||||||
|
(.doChat second-model req handler))))
|
||||||
|
[app path] (app-with (ts/config model))
|
||||||
|
token (mint-token! path "alice" 7)
|
||||||
|
resp (rpc app token (send-message "add a todo to buy milk"))]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(is (= "Added it." (get-in resp [:body :result :parts 0 :text])))))
|
||||||
|
|
||||||
|
(deftest invalid-jsonrpc-test
|
||||||
|
(let [[app path] (app-with (ts/config (ts/text-model "x")))
|
||||||
|
token (mint-token! path "alice" 7)]
|
||||||
|
(is (= "invalid JSON-RPC request" (get-in (rpc app token {:id 1 :method "message/send"}) [:body :error :message])))
|
||||||
|
(is (= "method not found: nope" (get-in (rpc app token {:jsonrpc "2.0" :id 1 :method "nope"}) [:body :error :message])))))
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
(ns tbd.agent.core-test
|
||||||
|
(:require [clojure.test :refer [deftest is testing]]
|
||||||
|
[tbd.agent.core :as agent]
|
||||||
|
[tbd.agent.test-support :as ts]))
|
||||||
|
|
||||||
|
(defn- turn! [config messages tools]
|
||||||
|
(let [{:keys [ok error kind]} (agent/start config "system prompt" messages tools)]
|
||||||
|
(if error
|
||||||
|
{:error error :kind kind}
|
||||||
|
(let [events (atom [])]
|
||||||
|
(ok (fn [e] (swap! events conj e)))
|
||||||
|
{:events @events}))))
|
||||||
|
|
||||||
|
(deftest not-configured
|
||||||
|
(is (= {:error "chat is not configured" :kind :unavailable}
|
||||||
|
(agent/start {} "sys" [{:role "user" :content "hi"}] nil))))
|
||||||
|
|
||||||
|
(deftest api-base-url-adds-v1
|
||||||
|
(testing "LM Studio's OpenAI-compatible endpoints live under /v1, which LangChain4j does not add
|
||||||
|
on its own (see tbd.agent.core/api-base-url); LMSTUDIO_URL is documented as the bare
|
||||||
|
server address, so this has to be added for every request to land"
|
||||||
|
(is (= "http://llm.test/v1" (#'agent/api-base-url "http://llm.test")))
|
||||||
|
(is (= "http://llm.test/v1" (#'agent/api-base-url "http://llm.test/v1")))))
|
||||||
|
|
||||||
|
(deftest a-plain-reply
|
||||||
|
(is (= {:events [{:thinking "hm"} {:content "Hel"} {:content "lo"} {:done true}]}
|
||||||
|
(turn! (ts/config (ts/thinking-model "hm" "Hel" "lo")) [{:role "user" :content "hi"}] nil))))
|
||||||
|
|
||||||
|
(deftest a-tool-call
|
||||||
|
(is (= {:events [{:tool-calls [{:id "call_1" :name "add" :arguments "{\"x\":1}"}]} {:done true}]}
|
||||||
|
(turn! (ts/config (ts/tool-call-model {:id "call_1" :name "add" :arguments "{\"x\":1}"}))
|
||||||
|
[{:role "user" :content "add 1"}]
|
||||||
|
[{:name "add" :description "adds" :inputSchema {:type "object" :properties {}}}]))))
|
||||||
|
|
||||||
|
(deftest no-reply-is-an-error
|
||||||
|
(is (= {:events [{:error "the LLM server sent no reply"}]}
|
||||||
|
(turn! (ts/config (ts/no-reply-model)) [{:role "user" :content "hi"}] nil))))
|
||||||
|
|
||||||
|
(deftest tools-optional
|
||||||
|
(is (= {:events [{:content "x"} {:done true}]}
|
||||||
|
(turn! (ts/config (ts/text-model "x")) [{:role "user" :content "hi"}] nil))))
|
||||||
|
|
||||||
|
(deftest connection-failure-is-caught-before-the-stream-starts
|
||||||
|
(testing "the model errors before ever saying anything: {:error}, no {:ok}"
|
||||||
|
(is (= {:error "the LLM server can't be reached or refused" :kind :upstream}
|
||||||
|
(turn! (ts/config (ts/error-model "boom")) [{:role "user" :content "hi"}] nil))))
|
||||||
|
(testing "the fake model itself throws synchronously"
|
||||||
|
(let [config (ts/config (ts/model-of (fn [_ _] (throw (RuntimeException. "no such host")))))]
|
||||||
|
(is (= :upstream (:kind (turn! config [{:role "user" :content "hi"}] nil))))))
|
||||||
|
(testing ".chat is never called when chat isn't configured, whatever the model would have said"
|
||||||
|
(let [calls (atom 0)
|
||||||
|
config (assoc (ts/config (ts/model-of (fn [_ _] (swap! calls inc)))) :url nil)]
|
||||||
|
(is (= {:error "chat is not configured" :kind :unavailable} (turn! config [] nil)))
|
||||||
|
(is (zero? @calls)))))
|
||||||
|
|
||||||
|
(deftest conversation-history-round-trips
|
||||||
|
(testing "assistant messages with tool calls, and their tool-role results, reach the model
|
||||||
|
without error (see tbd.agent.core/llm-message)"
|
||||||
|
(let [messages [{:role "user" :content "add 1 and 2"}
|
||||||
|
{:role "assistant" :content "" :tool-calls [{:id "c1" :name "add" :arguments "{\"x\":1,\"y\":2}"}]}
|
||||||
|
{:role "tool" :tool-call-id "c1" :content "3"}]]
|
||||||
|
(is (= {:events [{:content "3"} {:done true}]}
|
||||||
|
(turn! (ts/config (ts/text-model "3")) messages nil))))))
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
(ns tbd.agent.instructions-test
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is]]
|
||||||
|
[tbd.agent.instructions :as instructions]
|
||||||
|
[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
|
||||||
|
(doseq [text (map flat [(instructions/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 "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: "))))
|
||||||
|
|
||||||
|
(deftest chat-is-framed-as-the-in-page-chatbot
|
||||||
|
(let [text (instructions/chat)]
|
||||||
|
(is (str/includes? text "under your message"))
|
||||||
|
(is (str/includes? text "Chain calls without stopping"))
|
||||||
|
(is (str/includes? text "navigate there, then") "the chatbot's tools follow the page it is on")))
|
||||||
|
|
||||||
|
(deftest chat-system-message-adds-page-and-tools
|
||||||
|
(is (= (instructions/chat) (instructions/chat-system-message nil nil)))
|
||||||
|
(let [text (instructions/chat-system-message "todos" [{:name "create_todo"} {:name "list_todos"}])]
|
||||||
|
(is (str/includes? text "on the \"todos\" page"))
|
||||||
|
(is (str/includes? text "Its tools: create_todo, list_todos"))))
|
||||||
|
|
||||||
|
(deftest a2a-has-no-in-page-framing
|
||||||
|
(let [text (instructions/a2a)]
|
||||||
|
(is (str/includes? text "Act, don't offer"))
|
||||||
|
(is (str/includes? text "Agent2Agent protocol"))
|
||||||
|
(is (str/includes? text "no page open"))
|
||||||
|
(is (not (str/includes? text "under your message")))))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
(ns tbd.agent.prompt-test
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is]]
|
||||||
|
[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 guide-tells-the-agent-to-act
|
||||||
|
(let [text (flat 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: "))
|
||||||
|
(is (not (str/includes? text "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")))
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
(ns tbd.agent.test-support
|
||||||
|
"A fake LangChain4j StreamingChatModel for tests: no real network, no LM Studio. `model-of`
|
||||||
|
builds one from a script — a function of the ChatRequest that calls the handler's callbacks
|
||||||
|
directly, exactly as a real provider's client would, so tbd.agent.core's pump code is exercised
|
||||||
|
for real. `text-model`/`tool-call-model`/`error-model`/`thinking-model` cover the common cases."
|
||||||
|
(:require [tbd.agent.core :as agent])
|
||||||
|
(:import (dev.langchain4j.agent.tool ToolExecutionRequest)
|
||||||
|
(dev.langchain4j.data.message AiMessage)
|
||||||
|
(dev.langchain4j.model.chat StreamingChatModel)
|
||||||
|
(dev.langchain4j.model.chat.response
|
||||||
|
ChatResponse ChatResponseMetadata$Builder CompleteToolCall PartialResponse
|
||||||
|
PartialResponseContext PartialThinking PartialThinkingContext StreamingHandle)
|
||||||
|
(dev.langchain4j.model.output FinishReason TokenUsage)))
|
||||||
|
|
||||||
|
(defn fake-handle
|
||||||
|
"A StreamingHandle whose `cancel` is observable through the atom `cancelled?`."
|
||||||
|
([] (fake-handle (atom false)))
|
||||||
|
([cancelled?]
|
||||||
|
(reify StreamingHandle
|
||||||
|
(cancel [_] (reset! cancelled? true))
|
||||||
|
(isCancelled [_] @cancelled?))))
|
||||||
|
|
||||||
|
(defn model-of
|
||||||
|
"A StreamingChatModel whose `doChat` calls `(script req handler)`."
|
||||||
|
^StreamingChatModel [script]
|
||||||
|
(reify StreamingChatModel
|
||||||
|
(doChat [_ req handler] (script req handler) nil)))
|
||||||
|
|
||||||
|
(defn- complete [handler ai-message finish-reason usage]
|
||||||
|
(.onCompleteResponse handler
|
||||||
|
(-> (ChatResponse/builder)
|
||||||
|
(.aiMessage ai-message)
|
||||||
|
(.metadata (cond-> (ChatResponseMetadata$Builder.)
|
||||||
|
finish-reason (.finishReason finish-reason)
|
||||||
|
usage (.tokenUsage (TokenUsage. (int (first usage)) (int (second usage))))
|
||||||
|
true .build))
|
||||||
|
.build)))
|
||||||
|
|
||||||
|
(defn text-model
|
||||||
|
"A model that streams `pieces` as :content, then finishes with their concatenation as the reply."
|
||||||
|
[& pieces]
|
||||||
|
(model-of (fn [_ handler]
|
||||||
|
(let [h (fake-handle)]
|
||||||
|
(doseq [p pieces] (.onPartialResponse handler (PartialResponse. ^String p) (PartialResponseContext. h)))
|
||||||
|
(complete handler (AiMessage/from (apply str pieces)) FinishReason/STOP [10 5])))))
|
||||||
|
|
||||||
|
(defn thinking-model
|
||||||
|
"A model that streams `thinking`, then `pieces` as content."
|
||||||
|
[thinking & pieces]
|
||||||
|
(model-of (fn [_ handler]
|
||||||
|
(let [h (fake-handle)]
|
||||||
|
(.onPartialThinking handler (PartialThinking. ^String thinking) (PartialThinkingContext. h))
|
||||||
|
(doseq [p pieces] (.onPartialResponse handler (PartialResponse. ^String p) (PartialResponseContext. h)))
|
||||||
|
(complete handler (AiMessage/from (apply str pieces)) FinishReason/STOP [10 5])))))
|
||||||
|
|
||||||
|
(defn tool-call-model
|
||||||
|
"A model that asks for one tool call: {:id :name :arguments}."
|
||||||
|
[{:keys [id name arguments]}]
|
||||||
|
(model-of (fn [_ handler]
|
||||||
|
(let [req (-> (ToolExecutionRequest/builder) (.id id) (.name name) (.arguments arguments) .build)]
|
||||||
|
(.onCompleteToolCall handler (CompleteToolCall. (int 0) req))
|
||||||
|
(complete handler (AiMessage/from "" [req]) FinishReason/TOOL_EXECUTION nil)))))
|
||||||
|
|
||||||
|
(defn error-model
|
||||||
|
"A model whose call always fails with `message`."
|
||||||
|
[message]
|
||||||
|
(model-of (fn [_ handler] (.onError handler (RuntimeException. ^String message)))))
|
||||||
|
|
||||||
|
(defn no-reply-model
|
||||||
|
"A model that ends the turn with nothing said and no calls (the LLM sent no reply)."
|
||||||
|
[]
|
||||||
|
(model-of (fn [_ handler] (complete handler (AiMessage/from "") FinishReason/STOP nil))))
|
||||||
|
|
||||||
|
(defn config
|
||||||
|
"A chat config (see tbd.agent.core/env-config) using `model` instead of a real LM Studio."
|
||||||
|
([model] (config model {}))
|
||||||
|
([model overrides]
|
||||||
|
(merge (agent/env-config {"LMSTUDIO_URL" "http://llm.test"}) {:chat-model model} overrides)))
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":%d}" login id)}))))
|
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":%d}" login id)}))))
|
||||||
|
|
||||||
(defn make-app
|
(defn make-app
|
||||||
"The app against a Gitea stub. `:todo` and `:oauth` (their configs) are passed through; other overrides go into the auth config."
|
"The app against a Gitea stub. `:db` and `:chat` (their configs) are passed through; other overrides go into the auth config."
|
||||||
[http & {:keys [todo oauth] :as overrides}]
|
[http & {:keys [db chat] :as overrides}]
|
||||||
(core/make-app {:todo todo
|
(core/make-app {:db db
|
||||||
:oauth oauth
|
:chat chat
|
||||||
:auth (merge {:base-url "http://tbd.test"
|
:auth (merge {:base-url "http://tbd.test"
|
||||||
:gitea-url "https://gitea.test"
|
:gitea-url "https://gitea.test"
|
||||||
:client-id "cid"
|
:client-id "cid"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
:session-secret "test-secret"
|
:session-secret "test-secret"
|
||||||
:secure-cookies? false
|
:secure-cookies? false
|
||||||
:http http}
|
:http http}
|
||||||
(dissoc overrides :todo :oauth))
|
(dissoc overrides :db :chat))
|
||||||
:nrepl {:port 0 :bind "127.0.0.1"}}))
|
:nrepl {:port 0 :bind "127.0.0.1"}}))
|
||||||
|
|
||||||
(defn request [app method uri & {:keys [cookie query]}]
|
(defn request [app method uri & {:keys [cookie query]}]
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
(ns tbd.chat.routes-test
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[clojure.test :refer [deftest is testing]]
|
||||||
|
[muuntaja.core :as m]
|
||||||
|
[ring.core.protocols :as ring-protocols]
|
||||||
|
[tbd.agent.test-support :as ts]
|
||||||
|
[tbd.auth.routes-test :as t]
|
||||||
|
[tbd.test-util :as test-util])
|
||||||
|
(:import (java.io ByteArrayInputStream ByteArrayOutputStream IOException OutputStream)))
|
||||||
|
|
||||||
|
(defn- json [data] (slurp (m/encode "application/json" data)))
|
||||||
|
|
||||||
|
(defn- post-chat
|
||||||
|
"POST /api/chat with `body`, a JSON string (or nil for none)."
|
||||||
|
[app cookie body]
|
||||||
|
(test-util/decode-body
|
||||||
|
(app (cond-> {:request-method :post :uri "/api/chat"
|
||||||
|
:headers (cond-> {"content-type" "application/json"}
|
||||||
|
cookie (assoc "cookie" cookie))}
|
||||||
|
body (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))))))
|
||||||
|
|
||||||
|
(defn- body-text
|
||||||
|
"What a response's streaming body writes, as the server would write it to the browser."
|
||||||
|
[resp]
|
||||||
|
(let [out (ByteArrayOutputStream.)]
|
||||||
|
(ring-protocols/write-body-to-stream (:body resp) resp out)
|
||||||
|
(.toString out "UTF-8")))
|
||||||
|
|
||||||
|
(defn- events
|
||||||
|
"The AG-UI events of a chat response: the JSON of each `data:` line, `:type` and one message/call
|
||||||
|
id dropped (a random uuid, not what these tests are about) unless a test keeps it."
|
||||||
|
[resp]
|
||||||
|
(->> (str/split (body-text resp) #"\n\n")
|
||||||
|
(map #(m/decode "application/json" (str/replace-first % "data: " "")))))
|
||||||
|
|
||||||
|
(defn- types [resp] (map :type (events resp)))
|
||||||
|
|
||||||
|
(def ^:private hello (json {:messages [{:id "m1" :role "user" :content "hi"}]}))
|
||||||
|
|
||||||
|
(defn- app-with [chat]
|
||||||
|
(t/make-app (t/gitea-stub "alice") :chat chat))
|
||||||
|
|
||||||
|
(deftest anonymous-test
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "x")))]
|
||||||
|
(doseq [body [hello "nope" nil]]
|
||||||
|
(let [resp (post-chat app nil body)]
|
||||||
|
(is (= 401 (:status resp)))
|
||||||
|
(is (= {:error "login required"} (:body resp)))))))
|
||||||
|
|
||||||
|
(deftest chat-test
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "Hello" ", Alice")))
|
||||||
|
cookie (t/login! app)]
|
||||||
|
(testing "the reply streams back as AG-UI server-sent events"
|
||||||
|
(let [resp (post-chat app cookie hello)]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(is (= "text/event-stream; charset=utf-8" (get-in resp [:headers "Content-Type"])))
|
||||||
|
(is (= "no-cache" (get-in resp [:headers "Cache-Control"])))
|
||||||
|
(is (= "no" (get-in resp [:headers "X-Accel-Buffering"])) "so a proxy doesn't hold the events back")
|
||||||
|
;; A streaming body drains a one-shot queue: read it exactly once and derive everything
|
||||||
|
;; from that, or a second read blocks forever waiting for events that already went by.
|
||||||
|
(let [evts (events resp)]
|
||||||
|
(is (= ["RUN_STARTED" "TEXT_MESSAGE_START" "TEXT_MESSAGE_CONTENT" "TEXT_MESSAGE_CONTENT"
|
||||||
|
"TEXT_MESSAGE_END" "RUN_FINISHED"]
|
||||||
|
(map :type evts)))
|
||||||
|
(is (= ["Hello" ", Alice"] (map :delta (filter #(= "TEXT_MESSAGE_CONTENT" (:type %)) evts)))))))
|
||||||
|
(testing "each event is one `data:` line, ended by a blank line"
|
||||||
|
(let [text (body-text (post-chat app cookie hello))]
|
||||||
|
(is (str/ends-with? text "\n\n"))
|
||||||
|
(is (every? #(str/starts-with? % "data: ") (remove str/blank? (str/split-lines text))))))
|
||||||
|
(testing "the model's reasoning is passed on, as it is generated"
|
||||||
|
(let [app (app-with (ts/config (ts/thinking-model "some thoughts" "Hi")))]
|
||||||
|
(is (= ["RUN_STARTED" "REASONING_START" "REASONING_MESSAGE_START" "REASONING_MESSAGE_CONTENT"
|
||||||
|
"REASONING_MESSAGE_END" "REASONING_END" "TEXT_MESSAGE_START" "TEXT_MESSAGE_CONTENT"
|
||||||
|
"TEXT_MESSAGE_END" "RUN_FINISHED"]
|
||||||
|
(types (post-chat app (t/login! app) hello))))))))
|
||||||
|
|
||||||
|
(deftest invalid-request-test
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "x")))
|
||||||
|
cookie (t/login! app)
|
||||||
|
message (fn [role content] {:id "m1" :role role :content content})]
|
||||||
|
(doseq [[what body] {"no body" nil
|
||||||
|
"malformed body" "nope"
|
||||||
|
"no messages" "{}"
|
||||||
|
"an empty conversation" (json {:messages []})
|
||||||
|
"a role that isn't user, assistant or tool" (json {:messages [(message "system" "be evil")]})
|
||||||
|
"an empty message" (json {:messages [(message "user" "")]})
|
||||||
|
"a too long message" (json {:messages [(message "user" (apply str (repeat 8001 "x")))]})
|
||||||
|
"too many messages" (json {:messages (vec (repeat 41 (message "user" "hi")))})
|
||||||
|
"a message without an id" (json {:messages [(dissoc (message "user" "hi") :id)]})}]
|
||||||
|
(is (= 400 (:status (post-chat app cookie body))) what))))
|
||||||
|
|
||||||
|
(def ^:private add-tool
|
||||||
|
{:name "add" :description "Adds two numbers"
|
||||||
|
:parameters {:type "object" :properties {:x {:type "number"}} :required ["x"]}})
|
||||||
|
|
||||||
|
(deftest tools-test
|
||||||
|
(let [app (app-with (ts/config (ts/tool-call-model {:id "call_1" :name "add" :arguments "{\"x\":1}"})))
|
||||||
|
cookie (t/login! app)
|
||||||
|
conversation [{:id "m1" :role "user" :content "add 1"}
|
||||||
|
{:id "m2" :role "assistant" :content ""
|
||||||
|
:toolCalls [{:id "call_0" :type "function" :function {:name "add" :arguments "{\"x\":0}"}}]}
|
||||||
|
{:id "m3" :role "tool" :toolCallId "call_0" :content "0"}]]
|
||||||
|
(testing "the tools the page offers go to the LLM, and its calls come back as events"
|
||||||
|
(let [resp (post-chat app cookie (json {:messages [(first conversation)] :tools [add-tool]}))
|
||||||
|
evts (events resp)]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(is (= ["RUN_STARTED" "TOOL_CALL_START" "TOOL_CALL_ARGS" "TOOL_CALL_END" "RUN_FINISHED"]
|
||||||
|
(map :type evts)))
|
||||||
|
(let [call (first (filter #(= "TOOL_CALL_START" (:type %)) evts))]
|
||||||
|
(is (= "call_1" (:toolCallId call)))
|
||||||
|
(is (= "add" (:toolCallName call))))
|
||||||
|
(is (= "{\"x\":1}" (:delta (first (filter #(= "TOOL_CALL_ARGS" (:type %)) evts)))))))
|
||||||
|
(testing "the conversation may hold calls and their results, which the LLM gets"
|
||||||
|
(is (= 200 (:status (post-chat app cookie (json {:messages conversation :tools [add-tool]}))))))
|
||||||
|
(testing "tools are optional"
|
||||||
|
(is (= 200 (:status (post-chat app cookie hello)))))))
|
||||||
|
|
||||||
|
(deftest page-test
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "x")))
|
||||||
|
cookie (t/login! app)]
|
||||||
|
(testing "the page the user is on reaches the LLM"
|
||||||
|
(is (= 200 (:status (post-chat app cookie
|
||||||
|
(json {:messages [{:id "m1" :role "user" :content "hi"}]
|
||||||
|
:context [{:description "page" :value "ai-setup"}]}))))))
|
||||||
|
(testing "it is bounded like any other string, but nothing enforces a slug shape on the wire"
|
||||||
|
(is (= 200 (:status (post-chat app cookie
|
||||||
|
(json {:messages [{:id "m1" :role "user" :content "hi"}]
|
||||||
|
:context [{:description "page" :value "ai-setup"}]}))))))))
|
||||||
|
|
||||||
|
(deftest invalid-tools-test
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "x")))
|
||||||
|
cookie (t/login! app)
|
||||||
|
user [{:id "m1" :role "user" :content "hi"}]
|
||||||
|
call {:id "c" :type "function" :function {:name "add" :arguments "{}"}}]
|
||||||
|
(doseq [[what body] {"a tool without a name" {:messages user :tools [(dissoc add-tool :name)]}
|
||||||
|
"a tool name that is no function name" {:messages user :tools [(assoc add-tool :name "add it!")]}
|
||||||
|
"a huge schema" {:messages user :tools [(assoc add-tool :parameters {:type "object" :description (apply str (repeat 6000 "x"))})]}
|
||||||
|
"too many tools" {:messages user :tools (vec (repeat 33 add-tool))}
|
||||||
|
"tools that aren't a list" {:messages user :tools add-tool}
|
||||||
|
"an empty assistant message" {:messages [{:id "m1" :role "assistant" :content ""}]}
|
||||||
|
"a call without an id" {:messages [{:id "m1" :role "assistant" :content "" :toolCalls [(dissoc call :id)]}]}
|
||||||
|
"too many calls" {:messages [{:id "m1" :role "assistant" :content "" :toolCalls (vec (repeat 17 call))}]}
|
||||||
|
"a result without the call's id" {:messages [{:id "m1" :role "tool" :content "3"}]}
|
||||||
|
"an empty result" {:messages [{:id "m1" :role "tool" :toolCallId "c" :content ""}]}
|
||||||
|
"a too long result" {:messages [{:id "m1" :role "tool" :toolCallId "c" :content (apply str (repeat 8001 "x"))}]}}]
|
||||||
|
(is (= 400 (:status (post-chat app cookie (json body)))) what))))
|
||||||
|
|
||||||
|
(deftest llm-failure-test
|
||||||
|
(testing "chat is off without an LLM server"
|
||||||
|
(let [app (app-with nil)
|
||||||
|
resp (post-chat app (t/login! app) hello)]
|
||||||
|
(is (= 503 (:status resp)))
|
||||||
|
(is (= {:error "chat is not configured"} (:body resp)))))
|
||||||
|
(testing "an LLM server that can't be reached: a bad gateway before any run starts"
|
||||||
|
(let [app (app-with (ts/config (ts/error-model "boom")))
|
||||||
|
resp (post-chat app (t/login! app) hello)]
|
||||||
|
(is (= 502 (:status resp)))
|
||||||
|
(is (= "the LLM server can't be reached or refused" (:error (:body resp))))))
|
||||||
|
(testing "the LLM sends no reply at all: it had already answered with something, even if empty,
|
||||||
|
so the run had already started"
|
||||||
|
(let [app (app-with (ts/config (ts/no-reply-model)))
|
||||||
|
resp (post-chat app (t/login! app) hello)]
|
||||||
|
(is (= 200 (:status resp)))
|
||||||
|
(is (= "RUN_ERROR" (:type (last (events resp))))))))
|
||||||
|
|
||||||
|
(deftest browser-goes-away-test
|
||||||
|
(testing "a browser that leaves mid-reply ends the stream quietly"
|
||||||
|
(let [app (app-with (ts/config (ts/text-model "a" "b" "c")))
|
||||||
|
resp (post-chat app (t/login! app) hello)
|
||||||
|
writes (atom 0)
|
||||||
|
gone (proxy [OutputStream] []
|
||||||
|
(write
|
||||||
|
([_] (swap! writes inc) (throw (IOException. "broken pipe")))
|
||||||
|
([_ _ _] (swap! writes inc) (throw (IOException. "broken pipe")))))]
|
||||||
|
(ring-protocols/write-body-to-stream (:body resp) resp gone)
|
||||||
|
(is (= 1 @writes) "it stops at the first write that fails"))))
|
||||||
@@ -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]))))))
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
[tbd.core :as core]
|
[tbd.core :as core]
|
||||||
[tbd.http :as http]
|
[tbd.http :as http]
|
||||||
[tbd.test-util :as test-util]
|
[tbd.test-util :as test-util]
|
||||||
[tbd.todo.db-test :as db-test])
|
[tbd.sqlite-test :as sqlite-test])
|
||||||
(:import (java.io ByteArrayInputStream)))
|
(:import (java.io ByteArrayInputStream)))
|
||||||
|
|
||||||
(defn call
|
(defn call
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
(is (= "application/edn; charset=utf-8" (content-type (call app :get "/api/todos" {}))))))))
|
(is (= "application/edn; charset=utf-8" (content-type (call app :get "/api/todos" {}))))))))
|
||||||
|
|
||||||
(deftest request-format-test
|
(deftest request-format-test
|
||||||
(let [app (t/make-app (t/gitea-stub "alice" 7) :todo {:db-path (db-test/temp-db-path)})
|
(let [app (t/make-app (t/gitea-stub "alice" 7) :db {:db-path (sqlite-test/temp-db-path)})
|
||||||
cookie (t/login! app)
|
cookie (t/login! app)
|
||||||
post (fn [headers body] (call app :post "/api/todos" (assoc headers "cookie" cookie) body))]
|
post (fn [headers body] (call app :post "/api/todos" (assoc headers "cookie" cookie) body))]
|
||||||
(testing "JSON in, EDN out by default"
|
(testing "JSON in, EDN out by default"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[malli.core :as malli]
|
[malli.core :as malli]
|
||||||
[tbd.oauth.core :as oauth]
|
[tbd.oauth.core :as oauth]
|
||||||
[tbd.oauth.db :as db]
|
[tbd.oauth.db :as db]
|
||||||
[tbd.oauth.db-test :as db-test]
|
[tbd.sqlite-test :as sqlite-test]
|
||||||
[tbd.oauth.routes-test :as o]
|
[tbd.oauth.routes-test :as o]
|
||||||
[tbd.auth.routes-test :as t])
|
[tbd.auth.routes-test :as t])
|
||||||
(:import (java.io ByteArrayInputStream)))
|
(:import (java.io ByteArrayInputStream)))
|
||||||
@@ -80,9 +80,9 @@
|
|||||||
(defn- fixture
|
(defn- fixture
|
||||||
"An app on a fresh database, with the tokens of an admin (alice) and a regular user (bob)."
|
"An app on a fresh database, with the tokens of an admin (alice) and a regular user (bob)."
|
||||||
[]
|
[]
|
||||||
(let [path (db-test/temp-db-path)]
|
(let [path (sqlite-test/temp-db-path)]
|
||||||
{:path path
|
{:path path
|
||||||
:app (t/make-app (t/gitea-stub "alice" 7) :oauth {:db-path path} :todo {:db-path (db-test/temp-db-path)})
|
:app (t/make-app (t/gitea-stub "alice" 7) :db {:db-path path})
|
||||||
:alice (mint-token! path "alice" 7)
|
:alice (mint-token! path "alice" 7)
|
||||||
:bob (mint-token! path "bob" 8)}))
|
:bob (mint-token! path "bob" 8)}))
|
||||||
|
|
||||||
@@ -99,8 +99,11 @@
|
|||||||
(is (= {:x "First addend" :y "Second addend"}
|
(is (= {:x "First addend" :y "Second addend"}
|
||||||
(update-vals (:properties add) :description)))
|
(update-vals (:properties add) :description)))
|
||||||
(is (= ["number" "number"] (map :type (vals (:properties add))))))
|
(is (= ["number" "number"] (map :type (vals (:properties add))))))
|
||||||
(is (= {:type "object" :required ["id"] :properties {:id {:type "integer" :description "Todo id"}}}
|
(let [get-todo (input-schema "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"] (:required (input-schema "update_todo"))) "title and done are optional")
|
||||||
(is (= #{"id" "title" "done"} (set (map name (keys (:properties (input-schema "update_todo"))))))))
|
(is (= #{"id" "title" "done"} (set (map name (keys (:properties (input-schema "update_todo"))))))))
|
||||||
(testing "and ones with no arguments say so"
|
(testing "and ones with no arguments say so"
|
||||||
@@ -174,22 +177,22 @@
|
|||||||
(is (= 401 (:status resp)))))))
|
(is (= 401 (:status resp)))))))
|
||||||
|
|
||||||
(deftest admin-follows-the-allowlist-test
|
(deftest admin-follows-the-allowlist-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
token (mint-token! path "alice" 7)
|
token (mint-token! path "alice" 7)
|
||||||
app-with (fn [admins] (t/make-app (t/gitea-stub "alice") :oauth {:db-path path} :admin-users admins))]
|
app-with (fn [admins] (t/make-app (t/gitea-stub "alice") :db {:db-path path} :admin-users admins))]
|
||||||
(is (= "alice (Gitea user 7), admin" (text (call-tool (app-with #{"alice"}) token (initialize (app-with #{"alice"}) token) "whoami" {}))))
|
(is (= "alice (Gitea user 7), admin" (text (call-tool (app-with #{"alice"}) token (initialize (app-with #{"alice"}) token) "whoami" {}))))
|
||||||
(testing "it is decided per request, not when the token was issued"
|
(testing "it is decided per request, not when the token was issued"
|
||||||
(let [app (app-with #{})]
|
(let [app (app-with #{})]
|
||||||
(is (= "alice (Gitea user 7)" (text (call-tool app token (initialize app token) "whoami" {}))))))))
|
(is (= "alice (Gitea user 7)" (text (call-tool app token (initialize app token) "whoami" {}))))))))
|
||||||
|
|
||||||
(deftest unconfigured-test
|
(deftest unconfigured-test
|
||||||
(let [app (t/make-app (t/gitea-stub "alice") :oauth {:db-path (db-test/temp-db-path)} :gitea-url nil)
|
(let [app (t/make-app (t/gitea-stub "alice") :db {:db-path (sqlite-test/temp-db-path)} :gitea-url nil)
|
||||||
resp (rpc app "anything" {:jsonrpc "2.0" :id 1 :method "initialize"})]
|
resp (rpc app "anything" {:jsonrpc "2.0" :id 1 :method "initialize"})]
|
||||||
(is (= 503 (:status resp)) "with no login there is no way to get a token, so /mcp isn't open instead")))
|
(is (= 503 (:status resp)) "with no login there is no way to get a token, so /mcp isn't open instead")))
|
||||||
|
|
||||||
(deftest end-to-end-test
|
(deftest end-to-end-test
|
||||||
(testing "a client that does the whole OAuth dance ends up as the user who approved it"
|
(testing "a client that does the whole OAuth dance ends up as the user who approved it"
|
||||||
(let [app (o/app-for (db-test/temp-db-path) "carol" 9)
|
(let [app (o/app-for (sqlite-test/temp-db-path) "carol" 9)
|
||||||
{:keys [client-id tokens]} (o/get-tokens! app)
|
{:keys [client-id tokens]} (o/get-tokens! app)
|
||||||
whoami (fn [access-token] (text (call-tool app access-token (initialize app access-token) "whoami" {})))]
|
whoami (fn [access-token] (text (call-tool app access-token (initialize app access-token) "whoami" {})))]
|
||||||
(is (= "carol (Gitea user 9)" (whoami (:access_token tokens))))
|
(is (= "carol (Gitea user 9)" (whoami (:access_token tokens))))
|
||||||
@@ -256,16 +259,18 @@
|
|||||||
|
|
||||||
(deftest tool-arguments-are-validated-test
|
(deftest tool-arguments-are-validated-test
|
||||||
(let [{:keys [app alice]} (fixture)
|
(let [{:keys [app alice]} (fixture)
|
||||||
|
todo-id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b"
|
||||||
sid (initialize app alice)
|
sid (initialize app alice)
|
||||||
error (fn [tool arguments]
|
error (fn [tool arguments]
|
||||||
(let [resp (call-tool app alice sid tool arguments)]
|
(let [resp (call-tool app alice sid tool arguments)]
|
||||||
(is (true? (get-in resp [:result :isError])) (str tool " " (pr-str arguments)))
|
(is (true? (get-in resp [:result :isError])) (str tool " " (pr-str arguments)))
|
||||||
(text resp)))]
|
(text resp)))]
|
||||||
(testing "arguments of the wrong type are turned away, which plumcp alone doesn't do"
|
(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: {: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}))))
|
(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"
|
(testing "the tools and the HTTP API validate against the same schemas"
|
||||||
(is (false? (malli/validate todo-schema/UpdateTodo {:id 1 :done "yes"})))
|
(is (false? (malli/validate todo-schema/UpdateTodo {:id todo-id :done "yes"})))
|
||||||
(is (true? (malli/validate todo-schema/UpdateTodo {:id 1 :done true}))))))
|
(is (true? (malli/validate todo-schema/UpdateTodo {:id todo-id :done true}))))))
|
||||||
|
|||||||
+32
-11
@@ -1,12 +1,9 @@
|
|||||||
(ns tbd.oauth.db-test
|
(ns tbd.oauth.db-test
|
||||||
(:require [clojure.java.io :as io]
|
(:require [clojure.java.io :as io]
|
||||||
[clojure.test :refer [deftest is testing]]
|
[clojure.test :refer [deftest is testing]]
|
||||||
[tbd.oauth.db :as db])
|
[next.jdbc :as jdbc]
|
||||||
(:import (java.nio.file Files)
|
[tbd.oauth.db :as db]
|
||||||
(java.nio.file.attribute FileAttribute)))
|
[tbd.sqlite-test :refer [temp-db-path]]))
|
||||||
|
|
||||||
(defn temp-db-path []
|
|
||||||
(str (Files/createTempDirectory "tbd-oauth" (make-array FileAttribute 0)) "/data/oauth.db"))
|
|
||||||
|
|
||||||
(defn client [id created-at]
|
(defn client [id created-at]
|
||||||
{:id id :name "Test client" :redirect-uris ["https://c.example.com/cb" "http://localhost/cb"] :created-at created-at})
|
{:id id :name "Test client" :redirect-uris ["https://c.example.com/cb" "http://localhost/cb"] :created-at created-at})
|
||||||
@@ -17,11 +14,6 @@
|
|||||||
:refresh-hash (str "refresh-" n) :refresh-expires-at 9000
|
:refresh-hash (str "refresh-" n) :refresh-expires-at 9000
|
||||||
:created-at 1000})
|
:created-at 1000})
|
||||||
|
|
||||||
(deftest env-config-test
|
|
||||||
(is (= {:db-path "data/oauth.db"} (db/env-config {})))
|
|
||||||
(is (= {:db-path "data/oauth.db"} (db/env-config {"OAUTH_DB_PATH" " "})))
|
|
||||||
(is (= {:db-path "/var/oauth.db"} (db/env-config {"OAUTH_DB_PATH" " /var/oauth.db "}))))
|
|
||||||
|
|
||||||
(deftest open-test
|
(deftest open-test
|
||||||
(testing "creates the file and its directory"
|
(testing "creates the file and its directory"
|
||||||
(let [path (temp-db-path)]
|
(let [path (temp-db-path)]
|
||||||
@@ -55,6 +47,7 @@
|
|||||||
(is (nil? (db/find-grant-by-access ds "refresh-1" 1000)) "a refresh token isn't an access token"))
|
(is (nil? (db/find-grant-by-access ds "refresh-1" 1000)) "a refresh token isn't an access token"))
|
||||||
(testing "found by refresh token"
|
(testing "found by refresh token"
|
||||||
(is (= 7 (:user-id (db/find-grant-by-refresh ds "refresh-1"))))
|
(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"))))
|
(is (nil? (db/find-grant-by-refresh ds "access-1"))))
|
||||||
(testing "rotating replaces both tokens, and works once per refresh token"
|
(testing "rotating replaces both tokens, and works once per refresh token"
|
||||||
(let [id (:id (db/find-grant-by-refresh ds "refresh-1"))
|
(let [id (:id (db/find-grant-by-refresh ds "refresh-1"))
|
||||||
@@ -88,3 +81,31 @@
|
|||||||
(is (nil? (db/find-grant-by-refresh ds "refresh-2")) "one that can't is dropped")
|
(is (nil? (db/find-grant-by-refresh ds "refresh-2")) "one that can't is dropped")
|
||||||
(is (nil? (db/take-code! ds "expired")))
|
(is (nil? (db/take-code! ds "expired")))
|
||||||
(is (some? (db/take-code! ds "pending")))))
|
(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})))))))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[tbd.auth.routes-test :as t]
|
[tbd.auth.routes-test :as t]
|
||||||
[tbd.oauth.core :as oauth]
|
[tbd.oauth.core :as oauth]
|
||||||
[tbd.oauth.db :as db]
|
[tbd.oauth.db :as db]
|
||||||
[tbd.oauth.db-test :as db-test]
|
[tbd.sqlite-test :as sqlite-test]
|
||||||
[tbd.test-util :as test-util])
|
[tbd.test-util :as test-util])
|
||||||
(:import (java.io ByteArrayInputStream)
|
(:import (java.io ByteArrayInputStream)
|
||||||
(java.net URLDecoder)))
|
(java.net URLDecoder)))
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
(defn app-for
|
(defn app-for
|
||||||
"An app whose OAuth database is `path`; the Gitea user is alice (id 7), an admin, unless told otherwise."
|
"An app whose OAuth database is `path`; the Gitea user is alice (id 7), an admin, unless told otherwise."
|
||||||
([path] (app-for path "alice" 7))
|
([path] (app-for path "alice" 7))
|
||||||
([path login id] (t/make-app (t/gitea-stub login id) :oauth {:db-path path})))
|
([path login id] (t/make-app (t/gitea-stub login id) :db {:db-path path})))
|
||||||
|
|
||||||
(defn call
|
(defn call
|
||||||
"Runs a request through `app`. `:form` (a map) is sent as a urlencoded body, `:body` (a string) as JSON."
|
"Runs a request through `app`. `:form` (a map) is sent as a urlencoded body, `:body` (a string) as JSON."
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
{:client-id client-id :cookie cookie :tokens (:body (token! app client-id code))}))
|
{:client-id client-id :cookie cookie :tokens (:body (token! app client-id code))}))
|
||||||
|
|
||||||
(deftest discovery-test
|
(deftest discovery-test
|
||||||
(let [app (app-for (db-test/temp-db-path))]
|
(let [app (app-for (sqlite-test/temp-db-path))]
|
||||||
(testing "protected resource metadata, at the root and at the resource's own path"
|
(testing "protected resource metadata, at the root and at the resource's own path"
|
||||||
(doseq [uri ["/.well-known/oauth-protected-resource" "/.well-known/oauth-protected-resource/mcp"]]
|
(doseq [uri ["/.well-known/oauth-protected-resource" "/.well-known/oauth-protected-resource/mcp"]]
|
||||||
(let [resp (call app :get uri)]
|
(let [resp (call app :get uri)]
|
||||||
@@ -112,8 +112,8 @@
|
|||||||
[:headers "Content-Type"]))))))
|
[:headers "Content-Type"]))))))
|
||||||
|
|
||||||
(deftest unconfigured-test
|
(deftest unconfigured-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
app (t/make-app (t/gitea-stub "alice") :oauth {:db-path path} :gitea-url nil)]
|
app (t/make-app (t/gitea-stub "alice") :db {:db-path path} :gitea-url nil)]
|
||||||
(doseq [[method uri] [[:get "/.well-known/oauth-protected-resource"]
|
(doseq [[method uri] [[:get "/.well-known/oauth-protected-resource"]
|
||||||
[:get "/.well-known/oauth-authorization-server"]
|
[:get "/.well-known/oauth-authorization-server"]
|
||||||
[:post "/oauth/register"]
|
[:post "/oauth/register"]
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
(is (= 503 (:status (call app method uri))) (str method " " uri)))))
|
(is (= 503 (:status (call app method uri))) (str method " " uri)))))
|
||||||
|
|
||||||
(deftest register-test
|
(deftest register-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
app (app-for path)]
|
app (app-for path)]
|
||||||
(testing "a client is registered as a public client"
|
(testing "a client is registered as a public client"
|
||||||
(let [resp (call app :post "/oauth/register"
|
(let [resp (call app :post "/oauth/register"
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
(is (= error (get-in resp [:body :error])) why))))))
|
(is (= error (get-in resp [:body :error])) why))))))
|
||||||
|
|
||||||
(deftest authorize-request-errors-test
|
(deftest authorize-request-errors-test
|
||||||
(let [app (app-for (db-test/temp-db-path))
|
(let [app (app-for (sqlite-test/temp-db-path))
|
||||||
client-id (register! app)
|
client-id (register! app)
|
||||||
cookie (t/login! app)
|
cookie (t/login! app)
|
||||||
open (fn [params] (call app :get "/oauth/authorize" :cookie cookie :query (auth/form-encode params)))]
|
open (fn [params] (call app :get "/oauth/authorize" :cookie cookie :query (auth/form-encode params)))]
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
:query (str "client_id=" client-id "&client_id=" client-id))))))))
|
:query (str "client_id=" client-id "&client_id=" client-id))))))))
|
||||||
|
|
||||||
(deftest login-then-consent-test
|
(deftest login-then-consent-test
|
||||||
(let [app (app-for (db-test/temp-db-path))
|
(let [app (app-for (sqlite-test/temp-db-path))
|
||||||
client-id (register! app)
|
client-id (register! app)
|
||||||
query (auth/form-encode (authorize-params client-id))]
|
query (auth/form-encode (authorize-params client-id))]
|
||||||
(testing "a logged-out user is sent to log in first, and back afterwards"
|
(testing "a logged-out user is sent to log in first, and back afterwards"
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
(is (str/includes? (:body page) "Test client")))))))))
|
(is (str/includes? (:body page) "Test client")))))))))
|
||||||
|
|
||||||
(deftest consent-page-test
|
(deftest consent-page-test
|
||||||
(let [app (app-for (db-test/temp-db-path))
|
(let [app (app-for (sqlite-test/temp-db-path))
|
||||||
client-id (register! app {:client_name "<script>alert(1)</script>"})
|
client-id (register! app {:client_name "<script>alert(1)</script>"})
|
||||||
{:keys [page csrf]} (consent! app (t/login! app) (authorize-params client-id))]
|
{:keys [page csrf]} (consent! app (t/login! app) (authorize-params client-id))]
|
||||||
(is (= 200 (:status page)))
|
(is (= 200 (:status page)))
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
(is (= "no-store" (get-in page [:headers "Cache-Control"]))))))
|
(is (= "no-store" (get-in page [:headers "Cache-Control"]))))))
|
||||||
|
|
||||||
(deftest consent-decision-test
|
(deftest consent-decision-test
|
||||||
(let [app (app-for (db-test/temp-db-path))
|
(let [app (app-for (sqlite-test/temp-db-path))
|
||||||
client-id (register! app)
|
client-id (register! app)
|
||||||
params (authorize-params client-id)
|
params (authorize-params client-id)
|
||||||
cookie (t/login! app)]
|
cookie (t/login! app)]
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
(is (nil? (get-in resp [:headers "Location"])))))))
|
(is (nil? (get-in resp [:headers "Location"])))))))
|
||||||
|
|
||||||
(deftest code-exchange-test
|
(deftest code-exchange-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
app (app-for path)
|
app (app-for path)
|
||||||
client-id (register! app)
|
client-id (register! app)
|
||||||
other-client-id (register! app)
|
other-client-id (register! app)
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
"the token endpoint takes forms, as RFC 6749 says"))))
|
"the token endpoint takes forms, as RFC 6749 says"))))
|
||||||
|
|
||||||
(deftest refresh-test
|
(deftest refresh-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
app (app-for path)
|
app (app-for path)
|
||||||
{:keys [client-id tokens]} (get-tokens! app)
|
{:keys [client-id tokens]} (get-tokens! app)
|
||||||
refresh! (fn [refresh-token & {:as overrides}]
|
refresh! (fn [refresh-token & {:as overrides}]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
(ns tbd.sqlite-test
|
||||||
|
(:require [clojure.java.io :as io]
|
||||||
|
[clojure.test :refer [deftest is testing]]
|
||||||
|
[next.jdbc :as jdbc]
|
||||||
|
[tbd.oauth.db :as oauth-db]
|
||||||
|
[tbd.sqlite :as sqlite]
|
||||||
|
[tbd.todo.db :as todo-db])
|
||||||
|
(:import (java.nio.file Files)
|
||||||
|
(java.nio.file.attribute FileAttribute)))
|
||||||
|
|
||||||
|
(defn temp-db-path
|
||||||
|
"A path for a database file inside a fresh temp directory (which `open` has to create a
|
||||||
|
subdirectory of, as with the default data/tbd.db)."
|
||||||
|
[]
|
||||||
|
(str (Files/createTempDirectory "tbd" (make-array FileAttribute 0)) "/data/tbd.db"))
|
||||||
|
|
||||||
|
(deftest env-config-test
|
||||||
|
(is (= {:db-path "data/tbd.db"} (sqlite/env-config {})))
|
||||||
|
(is (= {:db-path "data/tbd.db"} (sqlite/env-config {"DB_PATH" " "})))
|
||||||
|
(is (= {:db-path "/var/tbd.db"} (sqlite/env-config {"DB_PATH" " /var/tbd.db "}))))
|
||||||
|
|
||||||
|
(deftest open-test
|
||||||
|
(testing "creates the file and its directory"
|
||||||
|
(let [path (temp-db-path)]
|
||||||
|
(sqlite/open path todo-db/migrate!)
|
||||||
|
(is (.exists (io/file path)))))
|
||||||
|
(testing "no path is an error, not a database in the working directory"
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo (sqlite/open nil)))
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo (sqlite/open " "))))
|
||||||
|
(testing "every migration runs on the datasource, and again (harmlessly) on reopening"
|
||||||
|
(let [path (temp-db-path)
|
||||||
|
tables #(set (map :name (jdbc/execute! % ["SELECT name FROM sqlite_master WHERE type = 'table'"]
|
||||||
|
{:builder-fn next.jdbc.result-set/as-unqualified-maps})))]
|
||||||
|
(is (every? (tables (sqlite/open path todo-db/migrate! oauth-db/migrate!))
|
||||||
|
["todos" "clients" "codes" "grants"]))
|
||||||
|
(is (every? (tables (sqlite/open path todo-db/migrate! oauth-db/migrate!))
|
||||||
|
["todos" "clients" "codes" "grants"]))))
|
||||||
|
(testing "the groups keep their data apart in the one file"
|
||||||
|
(let [path (temp-db-path)
|
||||||
|
ds (sqlite/open path todo-db/migrate! oauth-db/migrate!)]
|
||||||
|
(todo-db/insert! ds {:owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||||
|
(oauth-db/insert-client! ds {:id "c1" :name "Test client" :redirect-uris ["http://localhost/cb"] :created-at 1})
|
||||||
|
(is (= ["buy milk"] (map :title (todo-db/list-todos ds 7))))
|
||||||
|
(is (= "c1" (:id (oauth-db/find-client ds "c1")))))))
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
(todo/new-todo 7 " buy milk " 1000)))
|
(todo/new-todo 7 " buy milk " 1000)))
|
||||||
(is (= {:error "title must not be blank"} (todo/new-todo 7 " " 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
|
(deftest apply-changes-test
|
||||||
(testing "title"
|
(testing "title"
|
||||||
|
|||||||
+44
-17
@@ -1,24 +1,13 @@
|
|||||||
(ns tbd.todo.db-test
|
(ns tbd.todo.db-test
|
||||||
(:require [clojure.java.io :as io]
|
(:require [clojure.java.io :as io]
|
||||||
[clojure.test :refer [deftest is testing]]
|
[clojure.test :refer [deftest is testing]]
|
||||||
[tbd.todo.db :as db])
|
[next.jdbc :as jdbc]
|
||||||
(:import (java.nio.file Files)
|
[tbd.sqlite-test :refer [temp-db-path]]
|
||||||
(java.nio.file.attribute FileAttribute)))
|
[tbd.todo.db :as db]))
|
||||||
|
|
||||||
(defn temp-db-path
|
|
||||||
"A path for a database file inside a fresh temp directory (which `open` has to create a
|
|
||||||
subdirectory of, as with the default data/todo.db)."
|
|
||||||
[]
|
|
||||||
(str (Files/createTempDirectory "tbd-todo" (make-array FileAttribute 0)) "/data/todo.db"))
|
|
||||||
|
|
||||||
(defn todo [owner-id title]
|
(defn todo [owner-id title]
|
||||||
{:owner-id owner-id :title title :done false :created-at 1000 :updated-at 1000})
|
{:owner-id owner-id :title title :done false :created-at 1000 :updated-at 1000})
|
||||||
|
|
||||||
(deftest env-config-test
|
|
||||||
(is (= {:db-path "data/todo.db"} (db/env-config {})))
|
|
||||||
(is (= {:db-path "data/todo.db"} (db/env-config {"TODO_DB_PATH" " "})))
|
|
||||||
(is (= {:db-path "/var/todo.db"} (db/env-config {"TODO_DB_PATH" " /var/todo.db "}))))
|
|
||||||
|
|
||||||
(deftest open-test
|
(deftest open-test
|
||||||
(testing "creates the file and its directory"
|
(testing "creates the file and its directory"
|
||||||
(let [path (temp-db-path)]
|
(let [path (temp-db-path)]
|
||||||
@@ -38,6 +27,7 @@
|
|||||||
id (:id saved)]
|
id (:id saved)]
|
||||||
(testing "insert returns the stored todo with an id"
|
(testing "insert returns the stored todo with an id"
|
||||||
(is (= (assoc (todo 7 "buy milk") :id id) saved))
|
(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"))))))
|
(is (not= id (:id (db/insert! ds (todo 7 "walk dog"))))))
|
||||||
(testing "find and list"
|
(testing "find and list"
|
||||||
(is (= saved (db/find-todo ds 7 id)))
|
(is (= saved (db/find-todo ds 7 id)))
|
||||||
@@ -47,9 +37,10 @@
|
|||||||
(db/update! ds 7 (assoc saved :title "buy oat milk" :done true :updated-at 2000))))
|
(db/update! ds 7 (assoc saved :title "buy oat milk" :done true :updated-at 2000))))
|
||||||
(is (= true (:done (db/find-todo ds 7 id)))))
|
(is (= true (:done (db/find-todo ds 7 id)))))
|
||||||
(testing "missing ids"
|
(testing "missing ids"
|
||||||
(is (nil? (db/find-todo ds 7 999)))
|
(let [missing (str (random-uuid))]
|
||||||
(is (nil? (db/update! ds 7 (assoc saved :id 999))))
|
(is (nil? (db/find-todo ds 7 missing)))
|
||||||
(is (false? (db/delete! ds 7 999))))
|
(is (nil? (db/update! ds 7 (assoc saved :id missing))))
|
||||||
|
(is (false? (db/delete! ds 7 missing)))))
|
||||||
(testing "delete"
|
(testing "delete"
|
||||||
(is (true? (db/delete! ds 7 id)))
|
(is (true? (db/delete! ds 7 id)))
|
||||||
(is (nil? (db/find-todo ds 7 id)))
|
(is (nil? (db/find-todo ds 7 id)))
|
||||||
@@ -67,3 +58,39 @@
|
|||||||
(is (nil? (db/update! ds 8 (assoc mine :title "hijacked"))))
|
(is (nil? (db/update! ds 8 (assoc mine :title "hijacked"))))
|
||||||
(is (false? (db/delete! ds 8 (:id mine))))
|
(is (false? (db/delete! ds 8 (:id mine))))
|
||||||
(is (= mine (db/find-todo ds 7 (: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)))))))))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[clojure.test :refer [deftest is testing]]
|
[clojure.test :refer [deftest is testing]]
|
||||||
[tbd.auth.routes-test :as t]
|
[tbd.auth.routes-test :as t]
|
||||||
[tbd.test-util :as test-util]
|
[tbd.test-util :as test-util]
|
||||||
[tbd.todo.db-test :as db-test])
|
[tbd.sqlite-test :as sqlite-test])
|
||||||
(:import (java.io ByteArrayInputStream)))
|
(:import (java.io ByteArrayInputStream)))
|
||||||
|
|
||||||
(defn request
|
(defn request
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
(test-util/decode-body resp)))
|
(test-util/decode-body resp)))
|
||||||
|
|
||||||
(defn app-for [db-path login id]
|
(defn app-for [db-path login id]
|
||||||
(t/make-app (t/gitea-stub login id) :todo {:db-path db-path}))
|
(t/make-app (t/gitea-stub login id) :db {:db-path db-path}))
|
||||||
|
|
||||||
(def ^:private todos "/api/todos")
|
(def ^:private todos "/api/todos")
|
||||||
|
|
||||||
(deftest anonymous-test
|
(deftest anonymous-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
app (app-for path "alice" 7)]
|
app (app-for path "alice" 7)]
|
||||||
(doseq [[method uri body] [[:get todos nil]
|
(doseq [[method uri body] [[:get todos nil]
|
||||||
[:post todos "{\"title\":\"x\"}"]
|
[:post todos "{\"title\":\"x\"}"]
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
(is (not (.exists (io/file path))) "the database isn't even opened")))
|
(is (not (.exists (io/file path))) "the database isn't even opened")))
|
||||||
|
|
||||||
(deftest lifecycle-test
|
(deftest lifecycle-test
|
||||||
(let [app (app-for (db-test/temp-db-path) "alice" 7)
|
(let [app (app-for (sqlite-test/temp-db-path) "alice" 7)
|
||||||
cookie (t/login! app)]
|
cookie (t/login! app)]
|
||||||
(testing "starts empty"
|
(testing "starts empty"
|
||||||
(let [resp (request app :get todos cookie)]
|
(let [resp (request app :get todos cookie)]
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{:keys [id] :as created} (:body resp)]
|
{:keys [id] :as created} (:body resp)]
|
||||||
(is (= 201 (:status resp)))
|
(is (= 201 (:status resp)))
|
||||||
(is (= {:owner-id 7 :title "buy milk" :done false} (select-keys created [:owner-id :title :done])))
|
(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"
|
(testing "get"
|
||||||
(is (= created (:body (request app :get (str todos "/" id) cookie)))))
|
(is (= created (:body (request app :get (str todos "/" id) cookie)))))
|
||||||
(testing "patch"
|
(testing "patch"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
(is (= ["walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))))))
|
(is (= ["walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))))))
|
||||||
|
|
||||||
(deftest validation-test
|
(deftest validation-test
|
||||||
(let [app (app-for (db-test/temp-db-path) "alice" 7)
|
(let [app (app-for (sqlite-test/temp-db-path) "alice" 7)
|
||||||
cookie (t/login! app)
|
cookie (t/login! app)
|
||||||
created (:body (request app :post todos cookie "{\"title\":\"keep\"}"))
|
created (:body (request app :post todos cookie "{\"title\":\"keep\"}"))
|
||||||
one (str todos "/" (:id created))]
|
one (str todos "/" (:id created))]
|
||||||
@@ -79,13 +79,14 @@
|
|||||||
(doseq [body ["{}" "{\"title\":\" \"}" "{\"done\":\"yes\"}" "{\"id\":99}"]]
|
(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 body))) body))
|
||||||
(is (= 400 (:status (request app :patch one cookie))) "no body"))
|
(is (= 400 (:status (request app :patch one cookie))) "no body"))
|
||||||
(testing "a non-numeric id"
|
(testing "an id that is not a UUID (an integer, as todos used to have, included)"
|
||||||
(doseq [method [:get :patch :delete]]
|
(doseq [method [:get :patch :delete]
|
||||||
(is (= 400 (:status (request app method (str todos "/abc") cookie "{\"done\":true}"))) (str method))))
|
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")))
|
(is (= [created] (:todos (:body (request app :get todos cookie)))) "nothing above changed the todo")))
|
||||||
|
|
||||||
(deftest per-user-scoping-test
|
(deftest per-user-scoping-test
|
||||||
(let [path (db-test/temp-db-path)
|
(let [path (sqlite-test/temp-db-path)
|
||||||
alice-app (app-for path "alice" 7)
|
alice-app (app-for path "alice" 7)
|
||||||
bob-app (app-for path "bob" 8)
|
bob-app (app-for path "bob" 8)
|
||||||
alice (t/login! alice-app)
|
alice (t/login! alice-app)
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
(is (= ["alice's"] (map :title (:todos (:body (request alice-app :get todos alice))))))
|
(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)))))))
|
(is (= ["bob's"] (map :title (:todos (:body (request bob-app :get todos bob)))))))
|
||||||
(testing "someone else's todo is indistinguishable from a missing one"
|
(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]]]
|
(doseq [[method body] [[:get nil] [:patch "{\"title\":\"hijacked\"}"] [:delete nil]]]
|
||||||
(let [resp (request bob-app method one bob body)]
|
(let [resp (request bob-app method one bob body)]
|
||||||
(is (= 404 (:status resp)) (str method))
|
(is (= 404 (:status resp)) (str method))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
(:require [clojure.test :refer [deftest is testing]]
|
(:require [clojure.test :refer [deftest is testing]]
|
||||||
[tbd.todo.core :as todo]
|
[tbd.todo.core :as todo]
|
||||||
[tbd.todo.db :as db]
|
[tbd.todo.db :as db]
|
||||||
[tbd.todo.db-test :as db-test]
|
[tbd.sqlite-test :as sqlite-test]
|
||||||
[tbd.todo.service :as service]))
|
[tbd.todo.service :as service]))
|
||||||
|
|
||||||
;; The rules every front end (HTTP routes, MCP tools) gets: ownership, not-found and validation.
|
;; The rules every front end (HTTP routes, MCP tools) gets: ownership, not-found and validation.
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
(def alice {:id 7 :login "alice"})
|
(def alice {:id 7 :login "alice"})
|
||||||
(def bob {:id 8 :login "bob"})
|
(def bob {:id 8 :login "bob"})
|
||||||
|
|
||||||
(defn- fresh-ds [] (let [path (db-test/temp-db-path)] (delay (db/open path))))
|
(defn- fresh-ds [] (let [path (sqlite-test/temp-db-path)] (delay (db/open path))))
|
||||||
|
|
||||||
(def ^:private not-found {:error "todo not found" :kind :not-found})
|
(def ^:private not-found {:error "todo not found" :kind :not-found})
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
id (:id created)]
|
id (:id created)]
|
||||||
(testing "create trims the title, makes it the user's and returns it stored"
|
(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 (= {: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/find-todo ds alice id)))
|
||||||
(is (= {:ok [created]} (service/list-todos ds alice)))
|
(is (= {:ok [created]} (service/list-todos ds alice)))
|
||||||
(testing "update changes the title and/or done"
|
(testing "update changes the title and/or done"
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
(deftest database-is-opened-on-use-test
|
(deftest database-is-opened-on-use-test
|
||||||
(testing "nothing opens the database until a service fn needs it"
|
(testing "nothing opens the database until a service fn needs it"
|
||||||
(let [opened (atom 0)
|
(let [opened (atom 0)
|
||||||
ds (delay (swap! opened inc) (db/open (db-test/temp-db-path)))]
|
ds (delay (swap! opened inc) (db/open (sqlite-test/temp-db-path)))]
|
||||||
(is (zero? @opened))
|
(is (zero? @opened))
|
||||||
(service/list-todos ds alice)
|
(service/list-todos ds alice)
|
||||||
(service/list-todos ds alice)
|
(service/list-todos ds alice)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"temp-directory": "target/c8",
|
||||||
|
"reports-dir": "../target/coverage/ui",
|
||||||
|
"reporter": ["text", "html", "lcovonly"],
|
||||||
|
"include": [".shadow-cljs/builds/test/dev/out/cljs-runtime/tbd.ui.*.js"],
|
||||||
|
"exclude": ["**/node_modules/**", "**/*_test.js", "**/tbd.ui.test_util.js"]
|
||||||
|
}
|
||||||
Generated
+1165
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -5,7 +5,9 @@
|
|||||||
"description": "ClojureScript (re-frame) front end for the tbd server",
|
"description": "ClojureScript (re-frame) front end for the tbd server",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "shadow-cljs watch app",
|
"watch": "shadow-cljs watch app",
|
||||||
"release": "shadow-cljs release app"
|
"release": "shadow-cljs release app",
|
||||||
|
"test": "shadow-cljs compile test && node -r ./test/dom-setup.cjs target/ui-test/test.js",
|
||||||
|
"coverage": "shadow-cljs compile test && c8 --reporter=none node -r ./test/dom-setup.cjs target/ui-test/test.js && cp -r src/tbd .shadow-cljs/builds/test/dev/out/cljs-runtime/ && c8 report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jason.today/webmcp": "0.1.13",
|
"@jason.today/webmcp": "0.1.13",
|
||||||
@@ -13,6 +15,8 @@
|
|||||||
"react-dom": "^19.3.0"
|
"react-dom": "^19.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"c8": "^12.0.0",
|
||||||
|
"jsdom": "^30.1.0",
|
||||||
"shadow-cljs": "^3.5.3"
|
"shadow-cljs": "^3.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -1,6 +1,7 @@
|
|||||||
;; The UI compiles straight into the server's resources dir, so the server picks it up from
|
;; 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`).
|
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`). ../shared holds the
|
||||||
{:source-paths ["src" "test"]
|
;; .cljc the UI and the server both read (the prompts an LLM is given, tbd.agent.prompt).
|
||||||
|
{:source-paths ["src" "test" "../shared"]
|
||||||
|
|
||||||
:dependencies
|
:dependencies
|
||||||
[[reagent "2.0.1"]
|
[[reagent "2.0.1"]
|
||||||
@@ -13,8 +14,9 @@
|
|||||||
:modules {:main {:init-fn tbd.ui.core/init}}
|
:modules {:main {:init-fn tbd.ui.core/init}}
|
||||||
:devtools {:after-load tbd.ui.core/reload}}
|
:devtools {:after-load tbd.ui.core/reload}}
|
||||||
|
|
||||||
;; script/ui-test: the tests run under Node, against fakes of fetch and navigator.modelContext.
|
;; script/ui-test (npm test): the tests run under Node, in a jsdom window (test/dom-setup.cjs,
|
||||||
|
;; which npm test preloads), against a fake server (fetch). Not :autorun: the preload has to
|
||||||
|
;; come first, so npm test starts node itself.
|
||||||
:test {:target :node-test
|
:test {:target :node-test
|
||||||
:output-to "target/ui-test/test.js"
|
:output-to "target/ui-test/test.js"
|
||||||
:ns-regexp "-test$"
|
:ns-regexp "-test$"}}}
|
||||||
:autorun true}}}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
(:require [re-frame.core :as rf]
|
(:require [re-frame.core :as rf]
|
||||||
[reagent.dom.client :as rdc]
|
[reagent.dom.client :as rdc]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.model-context :as model-context]
|
||||||
[tbd.ui.views :as views]
|
[tbd.ui.views :as views]
|
||||||
[tbd.ui.webmcp :as webmcp]))
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
@@ -10,15 +11,21 @@
|
|||||||
(defn- render []
|
(defn- render []
|
||||||
(rdc/render @root [views/app]))
|
(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."
|
||||||
|
[]
|
||||||
|
(model-context/register! webmcp/tools)
|
||||||
|
(webmcp/register!))
|
||||||
|
|
||||||
(defn reload
|
(defn reload
|
||||||
"Called by shadow-cljs after each hot reload."
|
"Called by shadow-cljs after each hot reload."
|
||||||
[]
|
[]
|
||||||
(rf/clear-subscription-cache!)
|
(rf/clear-subscription-cache!)
|
||||||
(webmcp/register!)
|
(offer-tools!)
|
||||||
(render))
|
(render))
|
||||||
|
|
||||||
(defn init []
|
(defn init []
|
||||||
(rf/dispatch-sync [::events/initialize])
|
(rf/dispatch-sync [::events/initialize])
|
||||||
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
||||||
(webmcp/register!)
|
(offer-tools!)
|
||||||
(render))
|
(render))
|
||||||
|
|||||||
+33
-2
@@ -2,7 +2,9 @@
|
|||||||
(:require [tbd.ui.tictactoe :as tictactoe]))
|
(:require [tbd.ui.tictactoe :as tictactoe]))
|
||||||
|
|
||||||
(def default-db
|
(def default-db
|
||||||
{:calc {:x "" :y ""
|
{;; The page being shown (see tbd.ui.pages); it follows the URL's fragment.
|
||||||
|
:page :tic-tac-toe
|
||||||
|
:calc {:x "" :y ""
|
||||||
;; {:status :idle}
|
;; {:status :idle}
|
||||||
;; {:status :loading}
|
;; {:status :loading}
|
||||||
;; {:status :ok :x "2" :y "3" :sum 5}
|
;; {:status :ok :x "2" :y "3" :sum 5}
|
||||||
@@ -19,9 +21,38 @@
|
|||||||
:items []
|
:items []
|
||||||
:new-title ""
|
:new-title ""
|
||||||
:error nil}
|
:error nil}
|
||||||
;; The "Add this MCP server to Claude Code" section: :copied? is true right after the
|
;; The "Add this MCP server to Claude Code" page: :copied? is true right after the
|
||||||
;; command was copied to the clipboard.
|
;; command was copied to the clipboard.
|
||||||
:mcp {:copied? false}
|
:mcp {:copied? false}
|
||||||
|
;; The chat widget (the green square in the corner): :open? is whether its panel is showing,
|
||||||
|
;; :messages the conversation so far, :input what is typed, :status :idle or :loading (waiting
|
||||||
|
;; for the reply, or for the tools it asked for), and :error the message of the last failed send.
|
||||||
|
;; A message is one of
|
||||||
|
;; {:role "user" :content "..."}
|
||||||
|
;; {:role "assistant" :content "..."}, with :tool-calls [{:id :name :arguments "{...}"}] when
|
||||||
|
;; the LLM asked for tools of the page to be called (:content may then be empty)
|
||||||
|
;; {:role "tool" :tool-call-id "..." :name "..." :content "..." :error? bool}: the result of
|
||||||
|
;; 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
|
||||||
|
;; Tool calls the server is still streaming (AG-UI's TOOL_CALL_START/ARGS/END), keyed by
|
||||||
|
;; toolCallId, until each is complete and moves into :pending-calls.
|
||||||
|
:tool-call-buffer {}
|
||||||
|
:thinking-at nil
|
||||||
|
:tool-calls-used 0}
|
||||||
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
|
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
|
||||||
:ttt {:board tictactoe/new-board
|
:ttt {:board tictactoe/new-board
|
||||||
:turn :x}})
|
:turn :x}})
|
||||||
|
|||||||
+431
-12
@@ -3,30 +3,130 @@
|
|||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[tbd.ui.db :as db]
|
[tbd.ui.db :as db]
|
||||||
|
[tbd.ui.model-context :as model-context]
|
||||||
|
[tbd.ui.pages :as pages]
|
||||||
|
[tbd.ui.sse :as sse]
|
||||||
[tbd.ui.tictactoe :as tictactoe]))
|
[tbd.ui.tictactoe :as tictactoe]))
|
||||||
|
|
||||||
|
;; A `js/fetch` of `uri` (GET unless `method` is given). A `body` is sent as EDN.
|
||||||
|
(defn- fetch-request [{:keys [uri method body]}]
|
||||||
|
(js/fetch uri (clj->js (cond-> {:method (or method "GET")}
|
||||||
|
body (assoc :headers {"Content-Type" "application/edn"}
|
||||||
|
:body (pr-str body))))))
|
||||||
|
|
||||||
|
(defn- response-map [resp text]
|
||||||
|
{:ok? (.-ok resp) :status (.-status resp) :body text})
|
||||||
|
|
||||||
;; Effect: request `uri` (GET unless `method` is given), then dispatch
|
;; Effect: 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.
|
;; `(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>}`.
|
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
|
||||||
(rf/reg-fx
|
(rf/reg-fx
|
||||||
::fetch
|
::fetch
|
||||||
(fn [{:keys [uri method body on-response]}]
|
(fn [{:keys [on-response] :as request}]
|
||||||
(-> (js/fetch uri (clj->js (cond-> {:method (or method "GET")}
|
(-> (fetch-request request)
|
||||||
body (assoc :headers {"Content-Type" "application/edn"}
|
|
||||||
:body (pr-str body)))))
|
|
||||||
(.then (fn [resp]
|
(.then (fn [resp]
|
||||||
(.then (.text resp)
|
(.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %))))))
|
||||||
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
|
|
||||||
:status (.-status resp)
|
|
||||||
:body %})))))
|
|
||||||
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
||||||
|
|
||||||
|
(def ^:private connection-lost {:type "RUN_ERROR" :message "the connection was lost"})
|
||||||
|
|
||||||
|
(def ^:private terminal-event-types
|
||||||
|
"The AG-UI event types (see tbd.chat.routes) that end a run."
|
||||||
|
#{"RUN_FINISHED" "RUN_ERROR"})
|
||||||
|
|
||||||
|
(defn- read-events!
|
||||||
|
"Dispatches `(conj on-event event)` for each AG-UI event (see tbd.ui.sse, tbd.chat.routes) in the
|
||||||
|
body read by `reader`, as they arrive. A body that ends without a RUN_FINISHED or RUN_ERROR event
|
||||||
|
(the connection dropped) is a RUN_ERROR event of its own, so whoever waits for the end always
|
||||||
|
gets one."
|
||||||
|
[reader on-event]
|
||||||
|
(let [decoder (js/TextDecoder. "utf-8")
|
||||||
|
ended? (atom false)
|
||||||
|
lost! #(when-not @ended?
|
||||||
|
(reset! ended? true)
|
||||||
|
(rf/dispatch (conj on-event connection-lost)))
|
||||||
|
step (fn step [pending]
|
||||||
|
(-> (.read reader)
|
||||||
|
(.then (fn [^js chunk]
|
||||||
|
(if (.-done chunk)
|
||||||
|
(lost!)
|
||||||
|
(let [[events pending] (sse/parse (str pending (.decode decoder (.-value chunk)
|
||||||
|
#js {:stream true})))]
|
||||||
|
(doseq [event events]
|
||||||
|
(when (contains? terminal-event-types (:type event)) (reset! ended? true))
|
||||||
|
(rf/dispatch (conj on-event event)))
|
||||||
|
(step pending)))))
|
||||||
|
(.catch (fn [_] (lost!)))))]
|
||||||
|
(step "")))
|
||||||
|
|
||||||
|
;; Effect: like ::fetch, for a response that is a stream of server-sent events. While it comes in,
|
||||||
|
;; `(conj on-event event)` is dispatched for each event, as a map (see read-events!). When the
|
||||||
|
;; request fails instead (a network error or a status that isn't 2xx: no stream came),
|
||||||
|
;; `(conj on-response {:ok? false :status int-or-nil :body string})` is dispatched, as ::fetch does.
|
||||||
|
(rf/reg-fx
|
||||||
|
::fetch-stream
|
||||||
|
(fn [{:keys [on-event on-response] :as request}]
|
||||||
|
(-> (fetch-request request)
|
||||||
|
(.then (fn [resp]
|
||||||
|
(if (.-ok resp)
|
||||||
|
(read-events! (.getReader (.-body resp)) on-event)
|
||||||
|
(.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %)))))))
|
||||||
|
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
||||||
|
|
||||||
|
;; Coeffect: the URL's fragment, which says which page to start on.
|
||||||
|
(rf/reg-cofx
|
||||||
|
::hash
|
||||||
|
(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))
|
||||||
|
|
||||||
|
(rf/reg-fx
|
||||||
|
::listen-for-hash
|
||||||
|
(fn [_]
|
||||||
|
(when-let [old @hash-listener]
|
||||||
|
(.removeEventListener js/window "hashchange" old))
|
||||||
|
(reset! hash-listener #(rf/dispatch [::hash-changed js/location.hash]))
|
||||||
|
(.addEventListener js/window "hashchange" @hash-listener)))
|
||||||
|
|
||||||
|
;; Effect: put `hash` in the URL, which is how a page gets its own address (and a Back button).
|
||||||
|
(rf/reg-fx
|
||||||
|
::set-hash
|
||||||
|
(fn [hash]
|
||||||
|
(set! js/location.hash hash)))
|
||||||
|
|
||||||
(rf/reg-event-fx
|
(rf/reg-event-fx
|
||||||
::initialize
|
::initialize
|
||||||
(fn [_ _]
|
[(rf/inject-cofx ::hash)]
|
||||||
{:db db/default-db
|
(fn [{:keys [hash]} _]
|
||||||
|
{:db (assoc db/default-db :page (pages/hash->page hash))
|
||||||
|
::listen-for-hash true
|
||||||
:dispatch [::load-user]}))
|
:dispatch [::load-user]}))
|
||||||
|
|
||||||
|
;; --- Pages ---
|
||||||
|
|
||||||
|
;; The URL changed (a link was followed, or Back was pressed): show the page it names.
|
||||||
|
(rf/reg-event-db
|
||||||
|
::hash-changed
|
||||||
|
(fn [db [_ hash]]
|
||||||
|
(assoc db :page (pages/hash->page hash))))
|
||||||
|
|
||||||
|
;; Go to a page without a link, as the browser tools do. The page changes right away; the URL
|
||||||
|
;; follows, and its change event finds the page already shown.
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::navigate
|
||||||
|
(fn [{:keys [db]} [_ page]]
|
||||||
|
{:db (assoc db :page page)
|
||||||
|
::set-hash (pages/page->hash page)}))
|
||||||
|
|
||||||
(rf/reg-event-db
|
(rf/reg-event-db
|
||||||
::set-operand
|
::set-operand
|
||||||
(fn [db [_ k value]]
|
(fn [db [_ k value]]
|
||||||
@@ -81,11 +181,18 @@
|
|||||||
(fn [_ _]
|
(fn [_ _]
|
||||||
{::fetch {:uri "/auth/logout" :method "POST" :on-response [::logout-response]}}))
|
{::fetch {:uri "/auth/logout" :method "POST" :on-response [::logout-response]}}))
|
||||||
|
|
||||||
|
(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 :tools-expanded?]))))
|
||||||
|
|
||||||
(rf/reg-event-db
|
(rf/reg-event-db
|
||||||
::logout-response
|
::logout-response
|
||||||
(fn [db [_ {:keys [ok?] :as resp}]]
|
(fn [db [_ {:keys [ok?] :as resp}]]
|
||||||
(if ok?
|
(if ok?
|
||||||
(assoc db :user {:status :anonymous} :todos (:todos db/default-db))
|
(-> db
|
||||||
|
(assoc :user {:status :anonymous} :todos (:todos db/default-db))
|
||||||
|
reset-chat)
|
||||||
(assoc-in db [:todos :error] (error-message resp)))))
|
(assoc-in db [:todos :error] (error-message resp)))))
|
||||||
|
|
||||||
;; --- Todos ---
|
;; --- Todos ---
|
||||||
@@ -169,7 +276,319 @@
|
|||||||
{:db (update-in db [:todos :items] (fn [items] (filterv #(not= id (:id %)) items)))}
|
{:db (update-in db [:todos :items] (fn [items] (filterv #(not= id (:id %)) items)))}
|
||||||
{:dispatch [::todo-failed resp]})))
|
{:dispatch [::todo-failed resp]})))
|
||||||
|
|
||||||
;; --- MCP setup section ---
|
;; --- Chat ---
|
||||||
|
|
||||||
|
;; The chat is a loop when the LLM uses the page's tools (see tbd.ui.model-context): the reply to a
|
||||||
|
;; message can be a request for tool calls instead of (or after) text; the page makes the calls,
|
||||||
|
;; and sends the conversation again, now with their results, for the LLM to go on from. It does
|
||||||
|
;; that until the LLM answers without asking for tools.
|
||||||
|
|
||||||
|
(def ^:private max-sent-messages
|
||||||
|
"How much of the conversation goes to the server with each message (its limit is
|
||||||
|
tbd.chat.schema/max-messages, which this stays within), the most recent part."
|
||||||
|
40)
|
||||||
|
|
||||||
|
(def ^:private max-tool-calls-per-turn
|
||||||
|
"How many tool calls the LLM may make in answer to one message of the user's, before the page
|
||||||
|
stops it. A model that keeps calling tools never answers, and every call is something done on the
|
||||||
|
user's page. (A turn is at most 1 + this many assistant messages and this many results, so it is
|
||||||
|
always inside `max-sent-messages`.)"
|
||||||
|
12)
|
||||||
|
|
||||||
|
(def ^:private max-tool-result-length
|
||||||
|
"The longest result of a tool that goes to the LLM (tbd.chat.schema/max-message-length)."
|
||||||
|
8000)
|
||||||
|
|
||||||
|
(def ^:private truncated " … (truncated)")
|
||||||
|
|
||||||
|
(defn- fit-result
|
||||||
|
"A tool's result text as the content of a message: never empty, and no longer than the server
|
||||||
|
takes."
|
||||||
|
[text]
|
||||||
|
(cond
|
||||||
|
(str/blank? text) "(no output)"
|
||||||
|
(> (count text) max-tool-result-length) (str (subs text 0 (- max-tool-result-length (count truncated)))
|
||||||
|
truncated)
|
||||||
|
:else text))
|
||||||
|
|
||||||
|
(defonce ^:private id-counter (atom 0))
|
||||||
|
|
||||||
|
(defn- next-id
|
||||||
|
"An id unique within this page's session, for an AG-UI message: they only have to identify a
|
||||||
|
message within one run's conversation, never across page loads."
|
||||||
|
[]
|
||||||
|
(str "m" (swap! id-counter inc)))
|
||||||
|
|
||||||
|
(defn- wire-message
|
||||||
|
"A message of the conversation as AG-UI's Message has it (see tbd.chat.schema/Message: user,
|
||||||
|
assistant with `toolCalls`, or tool)."
|
||||||
|
[{:keys [role content tool-calls tool-call-id]}]
|
||||||
|
(cond-> {:id (next-id) :role role}
|
||||||
|
(= "tool" role) (assoc :toolCallId tool-call-id :content content)
|
||||||
|
(not= "tool" role) (assoc :content content)
|
||||||
|
(seq tool-calls) (assoc :toolCalls (mapv (fn [{:keys [id name arguments]}]
|
||||||
|
{:id id :type "function"
|
||||||
|
:function {:name name :arguments arguments}})
|
||||||
|
tool-calls))))
|
||||||
|
|
||||||
|
(defn- recent
|
||||||
|
"The most recent `max-sent-messages` of `messages`, without the results of calls whose asking
|
||||||
|
is cut off: the LLM server refuses a result that answers nothing."
|
||||||
|
[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)))
|
||||||
|
|
||||||
|
(defn- wire-tool
|
||||||
|
"A tool as AG-UI's Tool has it (see tbd.chat.schema/Tool): `parameters`, not MCP's `inputSchema`."
|
||||||
|
[{:keys [name description inputSchema]}]
|
||||||
|
(cond-> {:name name :parameters inputSchema}
|
||||||
|
description (assoc :description description)))
|
||||||
|
|
||||||
|
;; The request that sends the conversation: an AG-UI RunAgentInput. It goes with the page being
|
||||||
|
;; shown (as a `context` entry, for the LLM to know where the user is) and the tools that page
|
||||||
|
;; offers the LLM. Both are read from the db every time, so a conversation that moves between
|
||||||
|
;; pages (the LLM's own `navigate`, say) always tells the LLM where it is now.
|
||||||
|
(defn- chat-request [db messages]
|
||||||
|
(let [tools (model-context/definitions (:page db))]
|
||||||
|
{:uri "/api/chat" :method "POST"
|
||||||
|
:body (cond-> {:threadId (next-id) :runId (next-id)
|
||||||
|
:messages (mapv wire-message (without-stale-results (recent messages)))
|
||||||
|
:context [{:description "page" :value (name (:page db))}]}
|
||||||
|
(seq tools) (assoc :tools (mapv wire-tool tools)))
|
||||||
|
:on-event [::chat-event]
|
||||||
|
:on-response [::chat-response]}))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::chat-toggle
|
||||||
|
(fn [db _]
|
||||||
|
(update-in db [:chat :open?] not)))
|
||||||
|
|
||||||
|
;; The panel is dragged to `width` px wide and its conversation `height` px high (the view keeps
|
||||||
|
;; them within the window).
|
||||||
|
(rf/reg-event-db
|
||||||
|
::chat-resize
|
||||||
|
(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]]
|
||||||
|
(assoc-in db [:chat :input] input)))
|
||||||
|
|
||||||
|
;; Only one message is in flight at a time, and a blank one isn't sent.
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::chat-send
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
(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)})))))
|
||||||
|
|
||||||
|
(defn- only-reasoning?
|
||||||
|
"Whether `message` is a reply that has come to nothing but the reasoning for it: there is nothing
|
||||||
|
in it for the LLM to be sent (the server refuses an assistant message that says and asks for
|
||||||
|
nothing)."
|
||||||
|
[{:keys [role content tool-calls]}]
|
||||||
|
(and (= "assistant" role) (str/blank? content) (empty? tool-calls)))
|
||||||
|
|
||||||
|
(defn- chat-failed
|
||||||
|
"The conversation after a send failed with `message`. If the last message is the user's (nothing
|
||||||
|
of the reply had come, but perhaps some reasoning, which goes), it leaves the conversation again
|
||||||
|
and goes back in the input, so trying once more is pressing Send (and the next message doesn't
|
||||||
|
follow one nobody answered). If part of the reply had come, or the tools' results had been sent,
|
||||||
|
that stays, and the next message follows it."
|
||||||
|
[db message]
|
||||||
|
(let [messages (get-in db [:chat :messages])
|
||||||
|
messages (cond-> messages (only-reasoning? (peek messages)) pop)
|
||||||
|
unanswered? (= "user" (:role (peek messages)))]
|
||||||
|
(update db :chat merge
|
||||||
|
{:status :idle :error message :pending-calls nil :messages messages}
|
||||||
|
(when unanswered?
|
||||||
|
{:messages (pop messages) :input (:content (peek messages))}))))
|
||||||
|
|
||||||
|
;; 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
|
||||||
|
::chat-response
|
||||||
|
(fn [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)))))
|
||||||
|
|
||||||
|
(defn- append-to-reply
|
||||||
|
"The conversation's assistant message being written, with `text` added to its content. The first
|
||||||
|
piece starts the message, the rest are added to it. Leading whitespace is dropped while nothing
|
||||||
|
has been said yet (the model often opens its answer with a stray blank line right after
|
||||||
|
reasoning), but preserved once the message has content, since it may be meaningful there."
|
||||||
|
[db text]
|
||||||
|
(update-in db [:chat :messages]
|
||||||
|
(fn [messages]
|
||||||
|
(if (= "assistant" (:role (peek messages)))
|
||||||
|
(update messages (dec (count messages)) update :content
|
||||||
|
(fn [content]
|
||||||
|
(str content (if (str/blank? content) (str/triml text) text))))
|
||||||
|
(conj messages {:role "assistant" :content (str/triml text)})))))
|
||||||
|
|
||||||
|
(defn- with-tool-calls
|
||||||
|
"The conversation with the calls the LLM asked for: on the reply's text (the message it
|
||||||
|
started), or on a message of their own when it said nothing."
|
||||||
|
[messages calls]
|
||||||
|
(if (= "assistant" (:role (peek messages)))
|
||||||
|
(update messages (dec (count messages)) assoc :tool-calls calls)
|
||||||
|
(conj messages {:role "assistant" :content "" :tool-calls calls})))
|
||||||
|
|
||||||
|
(defn- tool-message [{:keys [id name content error?]}]
|
||||||
|
{:role "tool" :tool-call-id id :name name :content content :error? (boolean error?)})
|
||||||
|
|
||||||
|
(defn- finish-reply
|
||||||
|
"The reply is complete. Without calls that is all; with them, they are made (see ::call-tools)
|
||||||
|
and the conversation goes on when they are done, unless this turn has used all the calls it may."
|
||||||
|
[db]
|
||||||
|
(let [{:keys [pending-calls tool-calls-used]} (:chat db)
|
||||||
|
used (+ (or tool-calls-used 0) (count pending-calls))
|
||||||
|
db (update db :chat assoc :pending-calls nil :thinking-at nil)]
|
||||||
|
(cond
|
||||||
|
(empty? pending-calls)
|
||||||
|
{:db (assoc-in db [:chat :status] :idle)}
|
||||||
|
|
||||||
|
(> 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."))}
|
||||||
|
|
||||||
|
:else
|
||||||
|
{:db (-> db
|
||||||
|
(update-in [:chat :messages] with-tool-calls pending-calls)
|
||||||
|
(assoc-in [:chat :tool-calls-used] used))
|
||||||
|
::call-tools {:page (:page db) :calls pending-calls :on-results [::chat-tool-results]}})))
|
||||||
|
|
||||||
|
(defn- text? [x]
|
||||||
|
(and (string? x) (seq x)))
|
||||||
|
|
||||||
|
(defn- start-tool-call [db {:keys [toolCallId toolCallName]}]
|
||||||
|
(update-in db [:chat :tool-call-buffer] assoc toolCallId {:id toolCallId :name toolCallName :arguments ""}))
|
||||||
|
|
||||||
|
(defn- append-tool-call-args [db {:keys [toolCallId delta]}]
|
||||||
|
(update-in db [:chat :tool-call-buffer toolCallId :arguments] str delta))
|
||||||
|
|
||||||
|
(defn- end-tool-call [db {:keys [toolCallId]}]
|
||||||
|
(let [call (get-in db [:chat :tool-call-buffer toolCallId])]
|
||||||
|
(cond-> db
|
||||||
|
call (-> (update-in [:chat :pending-calls] (fnil conj []) call)
|
||||||
|
(update-in [:chat :tool-call-buffer] dissoc toolCallId)))))
|
||||||
|
|
||||||
|
;; One AG-UI event of the reply (see tbd.chat.routes, https://docs.ag-ui.com): TEXT_MESSAGE_CONTENT
|
||||||
|
;; ({:delta "..."}) for the next piece of the reply (the first starts the assistant's message, the
|
||||||
|
;; rest are added to it), REASONING_MESSAGE_CONTENT for the next piece of the model's reasoning
|
||||||
|
;; (which is not kept: the user only sees that the model is thinking, and for as long as pieces
|
||||||
|
;; keep coming, which :thinking-at, the time the last one came, is for),
|
||||||
|
;; TOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_END as it asks for tools to be called (accumulated in
|
||||||
|
;; :tool-call-buffer until each call is complete), and last RUN_FINISHED or RUN_ERROR. Events
|
||||||
|
;; nobody is waiting for (the conversation was reset since) are dropped.
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::chat-event
|
||||||
|
[(rf/inject-cofx ::now)]
|
||||||
|
(fn [{:keys [db now]} [_ {:keys [type] :as event}]]
|
||||||
|
(let [loading? (= :loading (get-in db [:chat :status]))
|
||||||
|
db (cond-> db
|
||||||
|
(and loading? (= "REASONING_MESSAGE_CONTENT" type) (text? (:delta event)))
|
||||||
|
(assoc-in [:chat :thinking-at] now))]
|
||||||
|
(cond
|
||||||
|
(not loading?) {:db db}
|
||||||
|
(= "TEXT_MESSAGE_CONTENT" type) {:db (append-to-reply db (:delta event))}
|
||||||
|
(= "TOOL_CALL_START" type) {:db (start-tool-call db event)}
|
||||||
|
(= "TOOL_CALL_ARGS" type) {:db (append-tool-call-args db event)}
|
||||||
|
(= "TOOL_CALL_END" type) {:db (end-tool-call db event)}
|
||||||
|
(= "RUN_FINISHED" type) (finish-reply db)
|
||||||
|
(= "RUN_ERROR" type) {:db (chat-failed db (:message event))}
|
||||||
|
:else {:db db}))))
|
||||||
|
|
||||||
|
(defn- call-all
|
||||||
|
"A promise of the results of `calls`, in order, as {:id :name :content :error?}. One after the
|
||||||
|
other: the tools work on the same page (one may need the page the last one opened). Only the
|
||||||
|
tools `page` offers can be called."
|
||||||
|
[page calls]
|
||||||
|
(reduce (fn [results {:keys [id name arguments]}]
|
||||||
|
(.then results
|
||||||
|
(fn [done]
|
||||||
|
(.then (model-context/call! page name arguments)
|
||||||
|
(fn [{:keys [text error?]}]
|
||||||
|
(conj done {:id id :name name :content (fit-result text) :error? error?}))))))
|
||||||
|
(js/Promise.resolve [])
|
||||||
|
calls))
|
||||||
|
|
||||||
|
;; Effect: make the tool calls the LLM asked for, on the tools `page` (the one being shown) offers,
|
||||||
|
;; and dispatch `(conj on-results results)` when all are done: one {:id :name :content :error?}
|
||||||
|
;; for each, in order. A call that can't be made (an unknown tool, one the page doesn't offer,
|
||||||
|
;; arguments that aren't JSON, a tool that fails) is a result with :error?, for the LLM to read.
|
||||||
|
(rf/reg-fx
|
||||||
|
::call-tools
|
||||||
|
(fn [{:keys [page calls on-results]}]
|
||||||
|
(.then (call-all page calls) #(rf/dispatch (conj on-results %)))))
|
||||||
|
|
||||||
|
(defn- answers?
|
||||||
|
"Whether `results` are for the calls the conversation is waiting on. Results nobody waits for
|
||||||
|
(the conversation was reset while the tools ran) are not."
|
||||||
|
[db results]
|
||||||
|
(let [calls (:tool-calls (peek (get-in db [:chat :messages])))]
|
||||||
|
(and (= :loading (get-in db [:chat :status]))
|
||||||
|
(seq calls)
|
||||||
|
(= (map :id calls) (map :id results)))))
|
||||||
|
|
||||||
|
;; The tools have run: their results join the conversation, and it goes to the LLM again.
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::chat-tool-results
|
||||||
|
(fn [{:keys [db]} [_ results]]
|
||||||
|
(if-not (answers? db results)
|
||||||
|
{:db db}
|
||||||
|
(let [db (update-in db [:chat :messages] into (map tool-message) results)]
|
||||||
|
{: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
|
||||||
|
::chat-clear
|
||||||
|
(fn [db _]
|
||||||
|
(if (= :loading (get-in db [:chat :status]))
|
||||||
|
db
|
||||||
|
(reset-chat db))))
|
||||||
|
|
||||||
|
;; --- MCP setup page ---
|
||||||
|
|
||||||
;; Effect: put `text` on the clipboard. The Clipboard API only exists on secure origins (https
|
;; Effect: put `text` on the clipboard. The Clipboard API only exists on secure origins (https
|
||||||
;; or localhost), so the view doesn't offer the button without it; failures are ignored.
|
;; or localhost), so the view doesn't offer the button without it; failures are ignored.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
(ns tbd.ui.model-context
|
||||||
|
"The page's `modelContext`: the tools the page offers to an agent, as WebMCP names it (a page
|
||||||
|
registers tools with `navigator.modelContext` in the browsers that have one). Here it is the
|
||||||
|
registry the page's own chatbot reads. tbd.ui.core registers the tools of tbd.ui.webmcp, which
|
||||||
|
are also offered to an outside agent through the WebMCP widget; the chatbot is the same thing
|
||||||
|
embedded in the page.
|
||||||
|
|
||||||
|
The chatbot passes the tools' metadata (`definitions`, the shape of MCP's `tools/list`) to the
|
||||||
|
LLM along with the conversation. The LLM never runs anything: it answers with the calls it wants
|
||||||
|
made, tbd.ui.events intercepts them, `call!` invokes the tool right here in the page (the same
|
||||||
|
handlers as WebMCP's, so it shows on screen like a user's own doing) and the result goes back to
|
||||||
|
the LLM with the next request, as MCP's `tools/call` result would.
|
||||||
|
|
||||||
|
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 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."
|
||||||
|
(:require [clojure.string :as str]))
|
||||||
|
|
||||||
|
(defonce ^:private registry (atom []))
|
||||||
|
|
||||||
|
(defn register!
|
||||||
|
"Adds `tools` to what the page offers. A tool with the name of one already there replaces it
|
||||||
|
(which is what makes registering again, on hot reload, safe)."
|
||||||
|
[tools]
|
||||||
|
(let [names (set (map :name tools))]
|
||||||
|
(swap! registry (fn [registered] (into (filterv #(not (names (:name %))) registered) tools)))
|
||||||
|
nil))
|
||||||
|
|
||||||
|
(defn clear!
|
||||||
|
"Takes every tool away: for a test to start from a page that offers none."
|
||||||
|
[]
|
||||||
|
(reset! registry [])
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(defn- offered?
|
||||||
|
"Whether `tool` 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."
|
||||||
|
[page]
|
||||||
|
(into []
|
||||||
|
(comp (filter #(offered? page %))
|
||||||
|
(map (fn [{:keys [name description input-schema]}]
|
||||||
|
(cond-> {:name name :inputSchema input-schema}
|
||||||
|
description (assoc :description description)))))
|
||||||
|
@registry))
|
||||||
|
|
||||||
|
(defn- ->text [value]
|
||||||
|
(cond
|
||||||
|
(string? value) value
|
||||||
|
(nil? value) "ok"
|
||||||
|
:else (js/JSON.stringify (clj->js value))))
|
||||||
|
|
||||||
|
(defn run
|
||||||
|
"A promise of the result of `handler` on `args`, as {:text string :error? bool}. It never
|
||||||
|
rejects: a handler that throws, or whose promise rejects, is an error result."
|
||||||
|
[handler args]
|
||||||
|
(-> (js/Promise.resolve)
|
||||||
|
(.then #(handler args))
|
||||||
|
(.then (fn [value] {:text (->text value) :error? false}))
|
||||||
|
(.catch (fn [e] {:text (or (some-> e (aget "message")) (str e)) :error? true}))))
|
||||||
|
|
||||||
|
(defn- parse-arguments
|
||||||
|
"The arguments the LLM wrote, JSON text, as a map: nothing at all is no arguments (a model
|
||||||
|
calls a tool that takes none that way). Anything but a JSON object gives ::invalid."
|
||||||
|
[text]
|
||||||
|
(if (str/blank? text)
|
||||||
|
{}
|
||||||
|
(let [parsed (try (js->clj (js/JSON.parse text) :keywordize-keys true)
|
||||||
|
(catch :default _ ::invalid))]
|
||||||
|
(if (map? parsed) parsed ::invalid))))
|
||||||
|
|
||||||
|
(defn- failed [text]
|
||||||
|
(js/Promise.resolve {:text text :error? true}))
|
||||||
|
|
||||||
|
(defn call!
|
||||||
|
"Calls the tool `tool-name` on `arguments`, the JSON text the LLM wrote, and gives a promise of
|
||||||
|
its result, as {:text string :error? bool}. A tool that isn't there or isn't offered on `page`
|
||||||
|
(the LLM may be going by an earlier page's tools), or arguments that aren't a JSON object, is an
|
||||||
|
error result the LLM can read and try again from."
|
||||||
|
[page tool-name arguments]
|
||||||
|
(let [tool (first (filter #(= tool-name (:name %)) @registry))
|
||||||
|
args (parse-arguments arguments)]
|
||||||
|
(cond
|
||||||
|
(nil? tool) (failed (str "unknown tool: " tool-name))
|
||||||
|
(not (offered? page tool)) (failed (str "tool not available on this page: " tool-name
|
||||||
|
" (it is offered on the "
|
||||||
|
(str/join ", " (map name (sort (:pages tool))))
|
||||||
|
" page)"))
|
||||||
|
(= ::invalid args) (failed "invalid arguments: they must be a JSON object")
|
||||||
|
:else (run (:handler tool) args))))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
(ns tbd.ui.pages
|
||||||
|
"The pages of the app, and how they map to the URL. The server only serves the shell at `/`,
|
||||||
|
so a page lives in the fragment (`/#/todos`): following a link between pages never asks the
|
||||||
|
server for anything, and a page can still be bookmarked or reloaded.")
|
||||||
|
|
||||||
|
(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 :tic-tac-toe :title "Tic-tac-toe"}
|
||||||
|
{:id :calculator :title "Calculator"}
|
||||||
|
{:id :todos :title "Todos" :login? true}
|
||||||
|
{:id :ai-setup :title "AI setup"}])
|
||||||
|
|
||||||
|
(def default-page (:id (first pages)))
|
||||||
|
|
||||||
|
(def ^:private ids (into #{} (map :id) pages))
|
||||||
|
|
||||||
|
(defn page->hash
|
||||||
|
"The fragment that opens `page`: \"#/todos\"."
|
||||||
|
[page]
|
||||||
|
(str "#/" (name page)))
|
||||||
|
|
||||||
|
(defn hash->page
|
||||||
|
"The page a fragment (with or without its leading #) opens; the default page when it is empty
|
||||||
|
or names no page."
|
||||||
|
[hash]
|
||||||
|
(let [id (keyword (second (re-find #"^#?/?([^/?]*)" (or hash ""))))]
|
||||||
|
(if (contains? ids id) id default-page)))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
(ns tbd.ui.sse
|
||||||
|
"Reading server-sent events (what POST /api/chat answers with) out of the text that arrives in
|
||||||
|
pieces. Pure: tbd.ui.events does the fetching and feeds the pieces in."
|
||||||
|
(:require [clojure.string :as str]))
|
||||||
|
|
||||||
|
(defn- event-data
|
||||||
|
"The data of one event (the lines between blank lines) as a map, or nil when there is none
|
||||||
|
(a comment, say) or it isn't JSON."
|
||||||
|
[block]
|
||||||
|
(let [data (->> (str/split-lines block)
|
||||||
|
(keep #(second (re-matches #"data: ?(.*)" %))))]
|
||||||
|
(when (seq data)
|
||||||
|
(try (js->clj (js/JSON.parse (str/join "\n" data)) :keywordize-keys true)
|
||||||
|
(catch :default _ nil)))))
|
||||||
|
|
||||||
|
(defn parse
|
||||||
|
"`text` is what has been received so far and hasn't been parsed yet. Returns [events rest]: the
|
||||||
|
events that are complete in it (each the JSON of its `data:` lines, as a map), and the end of it,
|
||||||
|
an event that hasn't fully arrived, to put in front of the next piece."
|
||||||
|
[text]
|
||||||
|
(let [blocks (str/split (str/replace text "\r\n" "\n") #"\n\n" -1)]
|
||||||
|
[(vec (keep event-data (butlast blocks)))
|
||||||
|
(last blocks)]))
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
(:require [re-frame.core :as rf]
|
(:require [re-frame.core :as rf]
|
||||||
[tbd.ui.tictactoe :as tictactoe]))
|
[tbd.ui.tictactoe :as tictactoe]))
|
||||||
|
|
||||||
|
(rf/reg-sub ::page (fn [db _] (:page db)))
|
||||||
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
|
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
|
||||||
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
|
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
|
||||||
(rf/reg-sub ::user (fn [db _] (:user db)))
|
(rf/reg-sub ::user (fn [db _] (:user db)))
|
||||||
(rf/reg-sub ::todos (fn [db _] (:todos db)))
|
(rf/reg-sub ::todos (fn [db _] (:todos db)))
|
||||||
|
(rf/reg-sub ::chat (fn [db _] (:chat db)))
|
||||||
(rf/reg-sub ::mcp-copied? (fn [db _] (get-in db [:mcp :copied?])))
|
(rf/reg-sub ::mcp-copied? (fn [db _] (get-in db [:mcp :copied?])))
|
||||||
|
|
||||||
(rf/reg-sub ::ttt (fn [db _] (:ttt db)))
|
(rf/reg-sub ::ttt (fn [db _] (:ttt db)))
|
||||||
|
|||||||
+265
-32
@@ -1,7 +1,9 @@
|
|||||||
(ns tbd.ui.views
|
(ns tbd.ui.views
|
||||||
(:require [clojure.string :as str]
|
(:require [clojure.string :as str]
|
||||||
|
[reagent.core :as r]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.pages :as pages]
|
||||||
[tbd.ui.subs :as subs]
|
[tbd.ui.subs :as subs]
|
||||||
[tbd.ui.webmcp :as webmcp]))
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
@@ -23,15 +25,17 @@
|
|||||||
"Sends GET /api/add?x=..&y=.. on submit and shows the sum the server returns."
|
"Sends GET /api/add?x=..&y=.. on submit and shows the sum the server returns."
|
||||||
[]
|
[]
|
||||||
(let [result @(rf/subscribe [::subs/result])]
|
(let [result @(rf/subscribe [::subs/result])]
|
||||||
[:form {:on-submit (fn [e]
|
[:section
|
||||||
(.preventDefault e)
|
[:h2 "Calculator"]
|
||||||
(rf/dispatch [::events/add]))}
|
[:form {:on-submit (fn [e]
|
||||||
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
|
(.preventDefault e)
|
||||||
[operand-input :x "x"]
|
(rf/dispatch [::events/add]))}
|
||||||
"+"
|
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
|
||||||
[operand-input :y "y"]
|
[operand-input :x "x"]
|
||||||
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
|
"+"
|
||||||
[result-line result]]))
|
[operand-input :y "y"]
|
||||||
|
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
|
||||||
|
[result-line result]]]))
|
||||||
|
|
||||||
(defn- todo-item [{:keys [id title done]}]
|
(defn- todo-item [{:keys [id title done]}]
|
||||||
[:li {:style {:display "flex" :gap "0.5rem" :align-items "center" :margin "0.25rem 0"}}
|
[:li {:style {:display "flex" :gap "0.5rem" :align-items "center" :margin "0.25rem 0"}}
|
||||||
@@ -52,7 +56,7 @@
|
|||||||
^{:key (:id todo)} [todo-item todo])]))
|
^{:key (:id todo)} [todo-item todo])]))
|
||||||
|
|
||||||
(defn todos
|
(defn todos
|
||||||
"Your todos (GET/POST /api/todos, PATCH/DELETE /api/todos/:id). Only shown when logged in."
|
"Your todos (GET/POST /api/todos, PATCH/DELETE /api/todos/:id)."
|
||||||
[]
|
[]
|
||||||
(let [{:keys [new-title error] :as state} @(rf/subscribe [::subs/todos])]
|
(let [{:keys [new-title error] :as state} @(rf/subscribe [::subs/todos])]
|
||||||
[:section
|
[:section
|
||||||
@@ -105,20 +109,13 @@
|
|||||||
[]
|
[]
|
||||||
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
|
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
|
||||||
|
|
||||||
(defn- collapsible
|
|
||||||
"A section the reader can fold away by its heading (a native <details>, closed to start with)."
|
|
||||||
[title & body]
|
|
||||||
(into [:details {:style {:margin-top "2rem"}}
|
|
||||||
[:summary {:style {:cursor "pointer"}}
|
|
||||||
[:h2 {:style {:display "inline"}} title]]]
|
|
||||||
body))
|
|
||||||
|
|
||||||
(defn mcp-setup
|
(defn mcp-setup
|
||||||
"How to add this server to Claude Code: a command to copy, then signing in once."
|
"How to add this server to Claude Code: a command to copy, then signing in once."
|
||||||
[]
|
[]
|
||||||
(let [command (mcp-command)
|
(let [command (mcp-command)
|
||||||
copied? @(rf/subscribe [::subs/mcp-copied?])]
|
copied? @(rf/subscribe [::subs/mcp-copied?])]
|
||||||
[collapsible "Add this MCP server to Claude Code"
|
[:section
|
||||||
|
[:h2 "Add this MCP server to Claude Code"]
|
||||||
[:p "Run this in your terminal:"]
|
[:p "Run this in your terminal:"]
|
||||||
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
|
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
|
||||||
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
|
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
|
||||||
@@ -139,7 +136,8 @@
|
|||||||
"The page's own tools for a browser agent (WebMCP, see tbd.ui.webmcp): how to connect an MCP
|
"The page's own tools for a browser agent (WebMCP, see tbd.ui.webmcp): how to connect an MCP
|
||||||
client to them. The token is pasted into the WebMCP widget in the corner of the page."
|
client to them. The token is pasted into the WebMCP widget in the corner of the page."
|
||||||
[]
|
[]
|
||||||
[collapsible "Browser tools (WebMCP)"
|
[:section
|
||||||
|
[:h2 "Browser tools (WebMCP)"]
|
||||||
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator, the todos and the tic-tac-toe game) to "
|
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator, the todos and the tic-tac-toe game) to "
|
||||||
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
|
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
|
||||||
", and you see them work on the page. To connect:"]
|
", and you see them work on the page. To connect:"]
|
||||||
@@ -168,16 +166,251 @@
|
|||||||
"Log out"]
|
"Log out"]
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
(defn- login-prompt
|
||||||
|
"What the todos page says to someone who isn't logged in (nothing while we don't know yet)."
|
||||||
|
[{:keys [status message]}]
|
||||||
|
(case status
|
||||||
|
:anonymous [:section
|
||||||
|
[:h2 "Todos"]
|
||||||
|
[:p [:a {:href "/auth/login"} "Log in"] " to see your todos."]]
|
||||||
|
:error [:p {:role "alert" :style {:color "crimson"}} message]
|
||||||
|
nil))
|
||||||
|
|
||||||
|
(def ^:private chat-green "#28a745")
|
||||||
|
|
||||||
|
(defn- chat-bubble [{:keys [role content]}]
|
||||||
|
(let [user? (= "user" role)]
|
||||||
|
[:div {:style {:align-self (if user? "flex-end" "flex-start")
|
||||||
|
:max-width "85%" :padding "0.4rem 0.6rem" :border-radius "0.5rem"
|
||||||
|
:white-space "pre-wrap" :overflow-wrap "anywhere"
|
||||||
|
:background (if user? chat-green "#f1f1f1")
|
||||||
|
:color (if user? "white" "#222")}}
|
||||||
|
content]))
|
||||||
|
|
||||||
|
(defn- chat-tool-line
|
||||||
|
"A tool the assistant calls, or what came of it, apart from what is said, and in smaller print.
|
||||||
|
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]}]
|
||||||
|
(str "Calling " name (when-not (str/blank? arguments) (str " " arguments)) "…"))
|
||||||
|
|
||||||
|
(defn- result-text [{:keys [name content error?]}]
|
||||||
|
(str name (if error? " failed: " " → ") content))
|
||||||
|
|
||||||
|
(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."
|
||||||
|
[tools-expanded? {:keys [role content tool-calls] :as message}]
|
||||||
|
(if (= "tool" role)
|
||||||
|
[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 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)
|
||||||
|
;; What the panel has besides the conversation (header, form, padding, and the gap under the
|
||||||
|
;; window's top edge it keeps): the conversation can be as high as the window less this.
|
||||||
|
(def ^:private chat-chrome-height 220)
|
||||||
|
|
||||||
|
(def ^:private stuck-slack
|
||||||
|
"How far (px) from its end the conversation can be scrolled and still count as at its end."
|
||||||
|
24)
|
||||||
|
|
||||||
|
(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
|
||||||
|
(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 thinking-at tools-expanded?]} {:keys [height]}]
|
||||||
|
[:div {:role "log" :aria-live "polite"
|
||||||
|
:ref (fn [el]
|
||||||
|
(when el
|
||||||
|
(when (some #(= "user" (:role %)) (drop @seen messages))
|
||||||
|
(reset! stuck? true))
|
||||||
|
(reset! seen (count messages))
|
||||||
|
(when @stuck? (set! (.-scrollTop el) (.-scrollHeight el)))))
|
||||||
|
:on-scroll #(reset! stuck? (at-end? (.-target %)))
|
||||||
|
:style {:display "flex" :flex-direction "column" :gap "0.4rem"
|
||||||
|
:height (str height "px")
|
||||||
|
:max-height (str "max(" chat-min-height "px, calc(100vh - " chat-chrome-height "px))")
|
||||||
|
:overflow-y "auto" :margin "0.5rem 0"}}
|
||||||
|
(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 tools-expanded? message]))
|
||||||
|
(when (and (= :loading status) (not= "assistant" (:role (peek messages))))
|
||||||
|
[thinking-line thinking-at])])))
|
||||||
|
|
||||||
|
(defn- chat-form [{:keys [input status]}]
|
||||||
|
(let [loading? (= :loading status)]
|
||||||
|
[:form {:on-submit (fn [e]
|
||||||
|
(.preventDefault e)
|
||||||
|
(rf/dispatch [::events/chat-send]))
|
||||||
|
:style {:display "flex" :gap "0.4rem"}}
|
||||||
|
;; Read-only while waiting (rather than disabled) so it keeps the focus.
|
||||||
|
[:input {:type "text" :name "message" :aria-label "Message" :placeholder "Message"
|
||||||
|
:auto-focus true :auto-complete "off" :read-only loading?
|
||||||
|
:style {:flex 1 :min-width 0}
|
||||||
|
:value input
|
||||||
|
:on-change #(rf/dispatch [::events/chat-set-input (.. % -target -value)])}]
|
||||||
|
[:button {:type "submit" :disabled (or loading? (str/blank? input))} "Send"]]))
|
||||||
|
|
||||||
|
(defn- chat-body [{:keys [error size] :as chat} user]
|
||||||
|
(case (:status user)
|
||||||
|
:logged-in [:<>
|
||||||
|
[chat-log chat size]
|
||||||
|
(when error [:p {:role "alert" :style {:color "crimson" :margin "0 0 0.5rem"}} error])
|
||||||
|
[chat-form chat]]
|
||||||
|
:anonymous [:p [:a {:href "/auth/login"} "Log in"] " to chat."]
|
||||||
|
:error [:p {:role "alert" :style {:color "crimson"}} (:message user)]
|
||||||
|
nil))
|
||||||
|
|
||||||
|
(defn- clamp [lo n hi]
|
||||||
|
(max lo (min n hi)))
|
||||||
|
|
||||||
|
(defn- start-resize!
|
||||||
|
"The panel is stuck to the bottom left corner, so dragging its top right corner is what makes it
|
||||||
|
bigger: to the right widens it, up makes the conversation higher. The pointer is followed
|
||||||
|
through the window, wherever it goes, until it is let go."
|
||||||
|
[e {:keys [width height]}]
|
||||||
|
(when (zero? (.-button e))
|
||||||
|
(.preventDefault e)
|
||||||
|
(let [x0 (.-clientX e)
|
||||||
|
y0 (.-clientY e)
|
||||||
|
move (fn [e]
|
||||||
|
(rf/dispatch [::events/chat-resize
|
||||||
|
(clamp chat-min-width (+ width (- (.-clientX e) x0))
|
||||||
|
(- (.-innerWidth js/window) 40))
|
||||||
|
(clamp chat-min-height (- height (- (.-clientY e) y0))
|
||||||
|
(- (.-innerHeight js/window) chat-chrome-height))]))
|
||||||
|
stop (fn stop [_]
|
||||||
|
(.removeEventListener js/window "pointermove" move)
|
||||||
|
(.removeEventListener js/window "pointerup" stop)
|
||||||
|
(.removeEventListener js/window "pointercancel" stop))]
|
||||||
|
(.addEventListener js/window "pointermove" move)
|
||||||
|
(.addEventListener js/window "pointerup" stop)
|
||||||
|
(.addEventListener js/window "pointercancel" stop))))
|
||||||
|
|
||||||
|
(defn- resize-handle [size]
|
||||||
|
[:div {:role "separator" :aria-label "Resize chat" :title "Drag to resize"
|
||||||
|
:on-pointer-down #(start-resize! % size)
|
||||||
|
:style {:position "absolute" :top 0 :right 0 :width "14px" :height "14px"
|
||||||
|
: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 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")
|
||||||
|
:max-width "calc(100vw - 40px)" :padding "15px" :background "#fff" :color "#222"
|
||||||
|
:border "1px solid #e1e1e1" :border-radius "5px"
|
||||||
|
: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"
|
||||||
|
:style {:background "none" :border 0 :font-size "1.25rem" :line-height 1 :cursor "pointer"}
|
||||||
|
:on-click #(rf/dispatch [::events/chat-toggle])}
|
||||||
|
"×"]]
|
||||||
|
[chat-body chat user]
|
||||||
|
[resize-handle size]])
|
||||||
|
|
||||||
|
(defn chat-widget
|
||||||
|
"A green square in the bottom left corner that opens a chat with the LLM (POST /api/chat), the
|
||||||
|
way the WebMCP widget's blue square in the other corner opens its connection panel. The LLM can
|
||||||
|
use the page's tools (tbd.ui.model-context): the panel shows the calls it makes. Chat needs a
|
||||||
|
login, so someone who isn't logged in is asked to log in."
|
||||||
|
[]
|
||||||
|
(let [{:keys [open?] :as chat} @(rf/subscribe [::subs/chat])
|
||||||
|
user @(rf/subscribe [::subs/user])]
|
||||||
|
[:div {:style {:position "fixed" :left "20px" :bottom "20px" :z-index 9999
|
||||||
|
:font-family "Arial, sans-serif" :font-size "14px"}}
|
||||||
|
(when open? [chat-panel chat user])
|
||||||
|
[:button {:type "button" :aria-label "Chat" :title "Chat" :aria-expanded (boolean open?)
|
||||||
|
:style {:display "block" :width "30px" :height "30px" :padding 0 :border 0
|
||||||
|
:border-radius "4px" :cursor "pointer" :background chat-green
|
||||||
|
:box-shadow "0 2px 10px rgba(0,0,0,0.2)"}
|
||||||
|
:on-click #(rf/dispatch [::events/chat-toggle])}]]))
|
||||||
|
|
||||||
|
(defn- current-page [page user]
|
||||||
|
(case page
|
||||||
|
:tic-tac-toe [tic-tac-toe]
|
||||||
|
:calculator [calculator]
|
||||||
|
:todos (if (= :logged-in (:status user)) [todos] [login-prompt user])
|
||||||
|
:ai-setup [:<> [mcp-setup] [browser-tools]]))
|
||||||
|
|
||||||
|
(defn- tabs
|
||||||
|
"One link per page, the current one marked. The todos are only offered when logged in."
|
||||||
|
[current user]
|
||||||
|
[:nav {:aria-label "Pages" :style {:display "flex" :flex-wrap "wrap" :gap "1rem"
|
||||||
|
:border-bottom "1px solid GrayText" :padding-bottom "0.5rem"}}
|
||||||
|
(for [{:keys [id title login?]} pages/pages
|
||||||
|
:when (or (not login?) (= :logged-in (:status user)))]
|
||||||
|
^{:key id}
|
||||||
|
[:a (cond-> {:href (pages/page->hash id)}
|
||||||
|
(= id current) (assoc :aria-current "page" :style {:font-weight "bold"}))
|
||||||
|
title])])
|
||||||
|
|
||||||
(defn app []
|
(defn app []
|
||||||
(let [user @(rf/subscribe [::subs/user])]
|
(let [page @(rf/subscribe [::subs/page])
|
||||||
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
|
user @(rf/subscribe [::subs/user])]
|
||||||
[:h1 "tbd"]
|
[:<>
|
||||||
[calculator]
|
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
|
||||||
(when (= :logged-in (:status user))
|
[:h1 "tbd"]
|
||||||
[todos])
|
[tabs page user]
|
||||||
[tic-tac-toe]
|
[current-page page user]
|
||||||
[mcp-setup]
|
[:p {:style {:margin-top "2rem"}}
|
||||||
[browser-tools]
|
[:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
|
||||||
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
|
(when-let [link (auth-link user)]
|
||||||
(when-let [link (auth-link user)]
|
[:<> " · " link])]]
|
||||||
[:<> " · " link])]]))
|
[chat-widget]]))
|
||||||
|
|||||||
+296
-97
@@ -7,13 +7,28 @@
|
|||||||
the form, press a button, tick a box) by dispatching the same re-frame events the views do, so
|
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.
|
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
|
||||||
|
agent outside the page.
|
||||||
|
|
||||||
A tool takes its arguments as a map and returns a value or a promise of one: a string, or data
|
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
|
that becomes JSON. Throwing (or rejecting) becomes an error result the agent can read. Input
|
||||||
schemas are JSON Schema, written here: the UI has no Malli to share with the server's."
|
schemas are JSON Schema, written here: the UI has no Malli to share with the server's."
|
||||||
(:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP]
|
(:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP]
|
||||||
|
[clojure.string :as str]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[re-frame.db :as rf-db]
|
[re-frame.db :as rf-db]
|
||||||
|
[tbd.agent.prompt :as prompt]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.model-context :as model-context]
|
||||||
|
[tbd.ui.pages :as pages]
|
||||||
[tbd.ui.tictactoe :as tictactoe]))
|
[tbd.ui.tictactoe :as tictactoe]))
|
||||||
|
|
||||||
;; --- Reading and waiting for the app-db ---
|
;; --- Reading and waiting for the app-db ---
|
||||||
@@ -26,6 +41,12 @@
|
|||||||
|
|
||||||
(defn- fail! [message] (throw (js/Error. message)))
|
(defn- fail! [message] (throw (js/Error. message)))
|
||||||
|
|
||||||
|
(defn- show!
|
||||||
|
"Opens `page` (see tbd.ui.pages), the way a user would click its tab, so a tool that acts on
|
||||||
|
the page does it where it can be seen."
|
||||||
|
[page]
|
||||||
|
(rf/dispatch-sync [::events/navigate page]))
|
||||||
|
|
||||||
(defn- settled
|
(defn- settled
|
||||||
"A promise of the app-db as soon as `(pred db)` holds (right away if it already does), or a
|
"A promise of the app-db as soon as `(pred db)` holds (right away if it already does), or a
|
||||||
rejection after `timeout-ms`. Requests made by events finish some time after the event is
|
rejection after `timeout-ms`. Requests made by events finish some time after the event is
|
||||||
@@ -52,16 +73,22 @@
|
|||||||
(-> (settled #(not= :unknown (get-in % [:user :status])) "the login state")
|
(-> (settled #(not= :unknown (get-in % [:user :status])) "the login state")
|
||||||
(.then #(:user %))))
|
(.then #(:user %))))
|
||||||
|
|
||||||
(defn- logged-in-todos
|
(defn- logged-in!
|
||||||
"A promise of the todo list as the page shows it, or a rejection if there is no login (the page
|
"A promise that is fulfilled once the page knows someone is logged in, or rejected if no one is
|
||||||
hides the todos then) or the list could not be loaded."
|
(`what` is what needs the login: the page hides it then) or the page can't tell."
|
||||||
[]
|
[what]
|
||||||
(-> (known-user)
|
(-> (known-user)
|
||||||
(.then (fn [{:keys [status message]}]
|
(.then (fn [{:keys [status message]}]
|
||||||
(case status
|
(case status
|
||||||
:logged-in nil
|
:logged-in nil
|
||||||
:anonymous (fail! "not logged in: log in on the page to use the todos")
|
:anonymous (fail! (str "not logged in: log in on the page to use " what))
|
||||||
:error (fail! (str "could not tell who is logged in: " message)))))
|
:error (fail! (str "could not tell who is logged in: " message)))))))
|
||||||
|
|
||||||
|
(defn- logged-in-todos
|
||||||
|
"A promise of the todo list as the page shows it, or a rejection if there is no login (the page
|
||||||
|
hides the todos then) or the list could not be loaded."
|
||||||
|
[]
|
||||||
|
(-> (logged-in! "the todos")
|
||||||
(.then #(settled (fn [d] (contains? #{:ok :error} (get-in d [:todos :status]))) "the todos"))
|
(.then #(settled (fn [d] (contains? #{:ok :error} (get-in d [:todos :status]))) "the todos"))
|
||||||
(.then (fn [d]
|
(.then (fn [d]
|
||||||
(let [{:keys [status items error]} (:todos d)]
|
(let [{:keys [status items error]} (:todos d)]
|
||||||
@@ -86,18 +113,58 @@
|
|||||||
(first (filter #(= id (:id %)) items)))
|
(first (filter #(= id (:id %)) items)))
|
||||||
|
|
||||||
(defn- find-todo! [items id]
|
(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) ---
|
;; --- Argument checks (the page can't trust that the agent honours the schema) ---
|
||||||
|
|
||||||
(defn- check-id! [id]
|
(defn- check-id! [id]
|
||||||
(when-not (and (number? id) (js/Number.isInteger id))
|
(when-not (and (string? id) (seq id))
|
||||||
(fail! "invalid arguments: id must be an integer")))
|
(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 ---
|
;; --- The tools ---
|
||||||
|
|
||||||
(defn- ping [_] "pong")
|
(defn- ping [_] "pong")
|
||||||
|
|
||||||
|
(defn- navigate
|
||||||
|
"Opens a page, as a click on its tab would."
|
||||||
|
[{:keys [page]}]
|
||||||
|
(let [{:keys [id title login?] :as target} (when (string? page)
|
||||||
|
(first (filter #(= page (name (:id %))) pages/pages)))]
|
||||||
|
(when-not target
|
||||||
|
(fail! (str "invalid arguments: page must be one of " (str/join ", " (map (comp name :id) pages/pages)))))
|
||||||
|
(-> (if login? (logged-in! (str "the " title " page")) (js/Promise.resolve))
|
||||||
|
(.then (fn [_]
|
||||||
|
(show! id)
|
||||||
|
{:page (name id) :title title})))))
|
||||||
|
|
||||||
(defn- whoami [_]
|
(defn- whoami [_]
|
||||||
(-> (known-user)
|
(-> (known-user)
|
||||||
(.then (fn [{:keys [status login message]}]
|
(.then (fn [{:keys [status login message]}]
|
||||||
@@ -117,6 +184,7 @@
|
|||||||
(fail! "invalid arguments: x and y must be numbers"))
|
(fail! "invalid arguments: x and y must be numbers"))
|
||||||
(when (= :loading (get-in (db) [:calc :result :status]))
|
(when (= :loading (get-in (db) [:calc :result :status]))
|
||||||
(fail! "an addition is already in progress"))
|
(fail! "an addition is already in progress"))
|
||||||
|
(show! :calculator)
|
||||||
(rf/dispatch-sync [::events/set-operand :x (str x)])
|
(rf/dispatch-sync [::events/set-operand :x (str x)])
|
||||||
(rf/dispatch-sync [::events/set-operand :y (str y)])
|
(rf/dispatch-sync [::events/set-operand :y (str y)])
|
||||||
(rf/dispatch-sync [::events/add])
|
(rf/dispatch-sync [::events/add])
|
||||||
@@ -130,53 +198,81 @@
|
|||||||
(defn- list-todos [_]
|
(defn- list-todos [_]
|
||||||
(logged-in-todos))
|
(logged-in-todos))
|
||||||
|
|
||||||
(defn- get-todo [{:keys [id]}]
|
(defn- get-todo
|
||||||
(check-id! id)
|
"Reads todos by id, all of them or none."
|
||||||
|
[{:keys [todos]}]
|
||||||
|
(check-todos! todos #(check-id! (:id %)))
|
||||||
(-> (logged-in-todos)
|
(-> (logged-in-todos)
|
||||||
(.then #(find-todo! % id))))
|
(.then (fn [items] (mapv #(find-todo! items (:id %)) todos)))))
|
||||||
|
|
||||||
(defn- create-todo
|
(defn- create-one
|
||||||
"Types the title into the new-todo field and presses Add."
|
"Types the title into the new-todo field and presses Add. A promise of the todo that was created."
|
||||||
[{:keys [title]}]
|
[{:keys [title]}]
|
||||||
(when-not (and (string? title) (seq title))
|
(show! :todos)
|
||||||
(fail! "invalid arguments: title must be a non-empty string"))
|
(rf/dispatch-sync [::events/set-new-title title])
|
||||||
(-> (logged-in-todos)
|
;; The field is emptied when the todo was added; a failure leaves it as it is.
|
||||||
(.then (fn [_]
|
(-> (change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")
|
||||||
(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])))))
|
(.then #(last (get-in % [:todos :items])))))
|
||||||
|
|
||||||
(defn- set-todo-done
|
(defn- create-todo
|
||||||
"Ticks or unticks a todo's checkbox."
|
"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]}]
|
[{:keys [id done]}]
|
||||||
(check-id! id)
|
(let [todo (find-todo! (get-in (db) [:todos :items]) id)]
|
||||||
(when-not (boolean? done)
|
(if (= done (boolean (:done todo)))
|
||||||
(fail! "invalid arguments: done must be true or false"))
|
(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)
|
(-> (logged-in-todos)
|
||||||
(.then (fn [items]
|
(.then (fn [items]
|
||||||
(let [todo (find-todo! items id)]
|
(run! #(find-todo! items (:id %)) todos)
|
||||||
(if (= done (boolean (:done todo)))
|
(show! :todos)
|
||||||
todo
|
(in-order set-one-done todos)))))
|
||||||
(-> (change-todos [::events/set-done id done]
|
|
||||||
#(= done (boolean (:done (find-todo (get-in % [:todos :items]) id))))
|
(defn- delete-one
|
||||||
"the todo")
|
"Presses a todo's Delete button. A promise of its id."
|
||||||
(.then #(find-todo (get-in % [:todos :items]) 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
|
(defn- delete-todo
|
||||||
"Presses a todo's Delete button."
|
"Presses the Delete button of several todos. Every id is looked up before the first is deleted."
|
||||||
[{:keys [id]}]
|
[{:keys [todos]}]
|
||||||
(check-id! id)
|
(check-todos! todos #(check-id! (:id %)))
|
||||||
(-> (logged-in-todos)
|
(let [ids (distinct (map :id todos))]
|
||||||
(.then (fn [items]
|
(-> (logged-in-todos)
|
||||||
(find-todo! items id)
|
(.then (fn [items]
|
||||||
(change-todos [::events/delete-todo id]
|
(run! #(find-todo! items %) ids)
|
||||||
#(nil? (find-todo (get-in % [:todos :items]) id))
|
(show! :todos)
|
||||||
"the todo to go")))
|
(in-order delete-one ids)))
|
||||||
(.then (fn [_] "deleted"))))
|
(.then (fn [deleted] {:deleted deleted})))))
|
||||||
|
|
||||||
;; Tic-tac-toe is moves taken in turns: X, then O, on the same board. The agent can move for
|
;; Tic-tac-toe is moves taken in turns: X, then O, on the same board. A person plays on the same
|
||||||
;; either side, so every result says whose turn it is.
|
;; board, so a move names the side it is for, and is refused when it is not that side's turn: an
|
||||||
|
;; agent that lost track of whose turn it is cannot play the person's move (or both sides).
|
||||||
|
;; Every result says whose turn it is.
|
||||||
|
|
||||||
(defn- tic-tac-toe-state
|
(defn- tic-tac-toe-state
|
||||||
"The game as the page shows it: the 9 cells (row by row, cell 0 is the top left; null when
|
"The game as the page shows it: the 9 cells (row by row, cell 0 is the top left; null when
|
||||||
@@ -194,107 +290,188 @@
|
|||||||
(tic-tac-toe-state))
|
(tic-tac-toe-state))
|
||||||
|
|
||||||
(defn- move-tic-tac-toe
|
(defn- move-tic-tac-toe
|
||||||
"Presses cell `cell`, which moves for whoever's turn it is. With `player`, only if it is that
|
"Presses cell `cell` as `player` (\"x\" or \"o\"), only if it is that player's turn."
|
||||||
player's turn: a guard against moving for the wrong side."
|
|
||||||
[{:keys [cell player]}]
|
[{:keys [cell player]}]
|
||||||
(when-not (and (number? cell) (js/Number.isInteger cell) (<= 0 cell 8))
|
(when-not (and (number? cell) (js/Number.isInteger cell) (<= 0 cell 8))
|
||||||
(fail! "invalid arguments: cell must be an integer from 0 to 8"))
|
(fail! "invalid arguments: cell must be an integer from 0 to 8"))
|
||||||
(when-not (contains? #{nil "x" "o"} player)
|
(when-not (contains? #{"x" "o"} player)
|
||||||
(fail! "invalid arguments: player must be \"x\" or \"o\""))
|
(fail! "invalid arguments: player must be \"x\" or \"o\""))
|
||||||
(let [{:keys [board turn]} (:ttt (db))]
|
(let [{:keys [board turn]} (:ttt (db))]
|
||||||
(when (tictactoe/outcome board)
|
(when (tictactoe/outcome board)
|
||||||
(fail! "the game is over: start a new game"))
|
(fail! "the game is over: start a new game"))
|
||||||
(when (and player (not= player (name turn)))
|
(when (not= player (name turn))
|
||||||
(fail! (str "it is " (name turn) "'s turn, not " player "'s")))
|
(fail! (str "it is " (name turn) "'s turn, not " player "'s: wait for the other side to move")))
|
||||||
(when (board cell)
|
(when (board cell)
|
||||||
(fail! (str "cell " cell " is taken")))
|
(fail! (str "cell " cell " is taken")))
|
||||||
|
(show! :tic-tac-toe)
|
||||||
(rf/dispatch-sync [::events/ttt-move cell])
|
(rf/dispatch-sync [::events/ttt-move cell])
|
||||||
(tic-tac-toe-state)))
|
(tic-tac-toe-state)))
|
||||||
|
|
||||||
(defn- new-tic-tac-toe-game
|
(defn- new-tic-tac-toe-game
|
||||||
"Presses New game."
|
"Presses New game."
|
||||||
[_]
|
[_]
|
||||||
|
(show! :tic-tac-toe)
|
||||||
(rf/dispatch-sync [::events/ttt-reset])
|
(rf/dispatch-sync [::events/ttt-reset])
|
||||||
(tic-tac-toe-state))
|
(tic-tac-toe-state))
|
||||||
|
|
||||||
(defn- object-schema [properties required]
|
(defn- object-schema [properties required]
|
||||||
{:type "object" :properties properties :required 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
|
(def tools
|
||||||
"The tools, as {:name :description :input-schema :handler}."
|
"The tools, as {:name :description :input-schema :handler :pages}. `pages` is where the page's
|
||||||
[{:name "ping" :description "Health check: returns pong"
|
own chatbot is offered the tool (see tbd.ui.model-context): the tools of a page's content are
|
||||||
|
only offered on that page, and the rest (no `pages`) on every page. An agent outside the page,
|
||||||
|
through WebMCP, is offered all of them."
|
||||||
|
[{:name "ping" :description "Health check: returns pong. Almost never useful: do not call it to look busy"
|
||||||
:input-schema (object-schema {} []) :handler ping}
|
:input-schema (object-schema {} []) :handler ping}
|
||||||
|
|
||||||
{:name "whoami"
|
{:name "whoami"
|
||||||
:description "Says who is logged in on this page, if anyone"
|
:description "Says who is logged in on this page, if anyone"
|
||||||
:input-schema (object-schema {} []) :handler whoami}
|
:input-schema (object-schema {} []) :handler whoami}
|
||||||
|
|
||||||
{:name "get_calculator"
|
{:name "navigate"
|
||||||
|
:description (str "Opens one of the pages of the app, as a click on its tab would, and returns the page "
|
||||||
|
"that is shown now (JSON). The pages are: "
|
||||||
|
(str/join ", " (map (fn [{:keys [id title login?]}]
|
||||||
|
(str (name id) " (" title (when login? ", needs a login") ")"))
|
||||||
|
pages/pages))
|
||||||
|
". Each page has tools of its own, which are on offer once you are on it. Go there "
|
||||||
|
"yourself, without asking, whenever what the user wants is on another page")
|
||||||
|
:input-schema (object-schema {:page {:type "string" :enum (mapv (comp name :id) pages/pages)
|
||||||
|
:description "The page to open"}}
|
||||||
|
["page"])
|
||||||
|
:handler navigate}
|
||||||
|
|
||||||
|
{:name "get_calculator" :pages #{:calculator}
|
||||||
:description (str "What the calculator form on this page holds (JSON): the x and y in its fields, and "
|
: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)")
|
"the result it is showing (status idle, loading, ok with the sum, or error with the message)")
|
||||||
:input-schema (object-schema {} []) :handler get-calculator}
|
:input-schema (object-schema {} []) :handler get-calculator}
|
||||||
|
|
||||||
{:name "add"
|
{:name "add" :pages #{:calculator}
|
||||||
:description "Types two numbers into the calculator form, presses Add, and returns the sum the page shows (JSON)"
|
: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"}
|
:input-schema (object-schema {:x {:type "number" :description "The first number"}
|
||||||
:y {:type "number" :description "The second number"}}
|
:y {:type "number" :description "The second number"}}
|
||||||
["x" "y"])
|
["x" "y"])
|
||||||
:handler add}
|
:handler add}
|
||||||
|
|
||||||
{:name "list_todos"
|
{:name "list_todos" :pages #{:todos}
|
||||||
:description (str "Lists the todos this page shows (JSON: id, title, done, created-at, updated-at), oldest first. "
|
:description (str "Lists the todos this page shows (JSON: id, title, done, created-at, updated-at), oldest first. "
|
||||||
"Needs a login on the page")
|
"Needs a login on the page")
|
||||||
:input-schema (object-schema {} []) :handler list-todos}
|
:input-schema (object-schema {} []) :handler list-todos}
|
||||||
|
|
||||||
{:name "get_todo"
|
{:name "get_todo" :pages #{:todos}
|
||||||
:description "Gets one of the todos this page shows, by id (JSON)"
|
:description (str "Gets todos this page shows, by id (JSON list). Takes a list: to read several, make ONE call "
|
||||||
:input-schema (object-schema {:id todo-id} ["id"]) :handler get-todo}
|
"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"
|
{:name "create_todo" :pages #{:todos}
|
||||||
:description "Types a title into the new-todo field, presses Add, and returns the todo that was created (JSON)"
|
:description (str "Adds todos: types each title into the new-todo field and presses Add, and returns the todos "
|
||||||
:input-schema (object-schema {:title {:type "string" :minLength 1 :maxLength 200
|
"that were created (JSON list). Takes a list: to add several, make ONE call with all of them, "
|
||||||
:description "What needs doing"}}
|
"never one call per todo")
|
||||||
["title"])
|
: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}
|
:handler create-todo}
|
||||||
|
|
||||||
{:name "set_todo_done"
|
{:name "set_todo_done" :pages #{:todos}
|
||||||
:description "Ticks or unticks a todo's checkbox and returns the todo (JSON)"
|
:description (str "Ticks or unticks the checkboxes of todos, and returns those todos (JSON list). Takes a list: "
|
||||||
:input-schema (object-schema {:id todo-id
|
"to change several, make ONE call with all of them, never one call per todo")
|
||||||
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
|
:input-schema (todos-schema "The changes to make, one per todo"
|
||||||
["id" "done"])
|
{:id todo-id
|
||||||
|
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
|
||||||
|
["id" "done"])
|
||||||
:handler set-todo-done}
|
:handler set-todo-done}
|
||||||
|
|
||||||
{:name "delete_todo"
|
{:name "delete_todo" :pages #{:todos}
|
||||||
:description "Presses a todo's Delete button"
|
:description (str "Deletes todos for good, by pressing their Delete buttons, and returns the ids that were "
|
||||||
:input-schema (object-schema {:id todo-id} ["id"])
|
"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}
|
:handler delete-todo}
|
||||||
|
|
||||||
{:name "get_tic_tac_toe"
|
{: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 "
|
: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 "
|
"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; "
|
"is, \"x\" or \"o\": X goes first, and they alternate) and status (in_progress, won or draw; "
|
||||||
"when won, also winner and line). A person moves on the same board, so check the turn "
|
"when won, also winner and line). A person moves on the same board, so check the turn "
|
||||||
"before you move")
|
"before you move, and only move for your own side")
|
||||||
:input-schema (object-schema {} []) :handler get-tic-tac-toe}
|
:input-schema (object-schema {} []) :handler get-tic-tac-toe}
|
||||||
|
|
||||||
{:name "move_tic_tac_toe"
|
{:name "move_tic_tac_toe" :pages #{:tic-tac-toe}
|
||||||
:description (str "Presses a cell of the tic-tac-toe board, which moves for whoever's turn it is, and returns "
|
:description (str "Presses a cell of the tic-tac-toe board as `player`, your side, and returns the game as "
|
||||||
"the game as get_tic_tac_toe does (including whose turn is next). Fails if the cell is "
|
"get_tic_tac_toe does (including whose turn is next). Fails, and changes nothing, if it "
|
||||||
"taken or the game is over")
|
"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
|
: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"}
|
:description "0 is the top left, 2 the top right, 8 the bottom right"}
|
||||||
:player {:type "string" :enum ["x" "o"]
|
:player {:type "string" :enum ["x" "o"]
|
||||||
:description (str "Optional: the side you are moving for. The move is "
|
:description (str "The side you play. The move is refused if it "
|
||||||
"refused if it is the other side's turn")}}
|
"is the other side's turn")}}
|
||||||
["cell"])
|
["cell" "player"])
|
||||||
:handler move-tic-tac-toe}
|
:handler move-tic-tac-toe}
|
||||||
|
|
||||||
{:name "new_tic_tac_toe_game"
|
{: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 (JSON, as get_tic_tac_toe)"
|
||||||
:input-schema (object-schema {} []) :handler new-tic-tac-toe-game}])
|
: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 ---
|
;; --- Registering them with WebMCP ---
|
||||||
|
|
||||||
(defn- text-result [text error?]
|
(defn- text-result [text error?]
|
||||||
@@ -305,21 +482,13 @@
|
|||||||
#js {"content" content "isError" true}
|
#js {"content" content "isError" true}
|
||||||
#js {"content" content})))
|
#js {"content" content})))
|
||||||
|
|
||||||
(defn- ->text [value]
|
|
||||||
(cond
|
|
||||||
(string? value) value
|
|
||||||
(nil? value) "ok"
|
|
||||||
:else (js/JSON.stringify (clj->js value))))
|
|
||||||
|
|
||||||
(defn- execute
|
(defn- execute
|
||||||
"A WebMCP `execute` callback for `handler`: runs it on the arguments, and turns what it
|
"A WebMCP `execute` callback for `handler`: runs it on the arguments, and turns what it
|
||||||
returns, or the error it throws, into a tool result."
|
returns, or the error it throws, into a tool result."
|
||||||
[handler]
|
[handler]
|
||||||
(fn [args]
|
(fn [args]
|
||||||
(-> (js/Promise.resolve)
|
(-> (model-context/run handler (js->clj args :keywordize-keys true))
|
||||||
(.then #(handler (js->clj args :keywordize-keys true)))
|
(.then (fn [{:keys [text error?]}] (text-result text error?))))))
|
||||||
(.then (fn [value] (text-result (->text value) false)))
|
|
||||||
(.catch (fn [e] (text-result (or (some-> e (aget "message")) (str e)) true))))))
|
|
||||||
|
|
||||||
(defn register-tools!
|
(defn register-tools!
|
||||||
"Registers `tools` on `mcp`, a WebMCP instance: `registerTool(name, description, inputSchema,
|
"Registers `tools` on `mcp`, a WebMCP instance: `registerTool(name, description, inputSchema,
|
||||||
@@ -330,11 +499,41 @@
|
|||||||
(js-invoke mcp "registerTool" name description (clj->js input-schema) (execute handler)))
|
(js-invoke mcp "registerTool" name description (clj->js input-schema) (execute handler)))
|
||||||
mcp)
|
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))
|
(defonce ^:private widget (atom nil))
|
||||||
|
|
||||||
(defn register!
|
(defn register!
|
||||||
"Offers `tools` to the agent through WebMCP. The first call creates the WebMCP widget (the small
|
"Offers `tools`, `prompts` and `resources` to the agent through WebMCP. The first call creates
|
||||||
square in the corner of the page, where the user pastes the token from their MCP client), later
|
the WebMCP widget (the small square in the corner of the page, where the user pastes the token
|
||||||
ones only register the tools again. Returns the WebMCP instance."
|
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!))
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Preloaded by `npm test` (node -r) before any compiled code runs: gives the ClojureScript
|
||||||
|
// tests a browser-like global scope (window, document, location, ...) backed by jsdom.
|
||||||
|
//
|
||||||
|
// It has to happen first: React DOM decides at load time whether the browser supports the
|
||||||
|
// `input` event; without a `window` then, <input onChange> never fires under test.
|
||||||
|
const { JSDOM } = require("jsdom");
|
||||||
|
|
||||||
|
const { window } = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', {
|
||||||
|
url: "http://localhost:3000/",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy the window's globals over, but leave Node's own (fetch, setTimeout, navigator, ...) alone.
|
||||||
|
for (const key of Object.getOwnPropertyNames(window)) {
|
||||||
|
if (!(key in globalThis)) {
|
||||||
|
Object.defineProperty(globalThis, key, {
|
||||||
|
configurable: true,
|
||||||
|
get: () => window[key],
|
||||||
|
set: (value) => Object.defineProperty(globalThis, key, { value, writable: true, configurable: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.window = window;
|
||||||
|
|
||||||
|
// WebMCP, for `require("@jason.today/webmcp/src/webmcp.js")`. Its only export is
|
||||||
|
// `module.exports = WebMCP`, guarded by `typeof module`, in a file its package.json declares as an
|
||||||
|
// ES module. The browser build wraps it as CommonJS, so there it exports the class; Node's
|
||||||
|
// require() loads it as ESM, where `module` doesn't exist, and gets nothing. So run the same
|
||||||
|
// source with a `module` to export to, and hand that to require.
|
||||||
|
const Module = require("module");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const webmcp = require.resolve("@jason.today/webmcp/src/webmcp.js");
|
||||||
|
const cjs = new Module(webmcp);
|
||||||
|
cjs.filename = webmcp;
|
||||||
|
new Function("module", "exports", fs.readFileSync(webmcp, "utf8"))(cjs, cjs.exports);
|
||||||
|
cjs.loaded = true;
|
||||||
|
require.cache[webmcp] = cjs;
|
||||||
|
|
||||||
|
// A run that doesn't finish must not look like a pass. An async test that throws before it calls
|
||||||
|
// `done` (or whose promise chain rejects) never ends: the process then hangs, or runs out of things
|
||||||
|
// to do and exits 0 without a word. Fail instead, and say which test it was.
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`\nFAILED: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) =>
|
||||||
|
fail(`unhandled promise rejection (an async test without a catch?): ${(reason && reason.stack) || reason}`));
|
||||||
|
|
||||||
|
// tbd.ui.test-util keeps `uiTestsCurrent` up to date: the test that is running, and since when.
|
||||||
|
const STUCK_MS = 30000;
|
||||||
|
globalThis.uiTestsCurrent = { name: "loading the tests", since: Date.now() };
|
||||||
|
setInterval(() => {
|
||||||
|
const { name, since } = globalThis.uiTestsCurrent;
|
||||||
|
if (Date.now() - since > STUCK_MS) {
|
||||||
|
fail(`stuck for ${STUCK_MS / 1000}s in ${name} (an async test that never called done?)`);
|
||||||
|
}
|
||||||
|
}, 1000).unref();
|
||||||
|
|
||||||
|
// ... and sets `uiTestsFinished` once cljs.test has printed its summary.
|
||||||
|
process.on("exit", (code) => {
|
||||||
|
if (code === 0 && !globalThis.uiTestsFinished) {
|
||||||
|
console.error("\nFAILED: the tests ended without reporting a result");
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
(ns tbd.ui.core-test
|
||||||
|
(:require [cljs.test :refer [deftest is async use-fixtures]]
|
||||||
|
[re-frame.db :as rf-db]
|
||||||
|
[reagent.dom.client :as rdc]
|
||||||
|
[tbd.ui.core :as core]
|
||||||
|
[tbd.ui.model-context :as model-context]
|
||||||
|
[tbd.ui.test-util :as tu]
|
||||||
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
|
;; init runs as in the browser, WebMCP widget included (the library is loaded through
|
||||||
|
;; test/webmcp-shim.cjs; see shadow-cljs.edn). Each test gets a fresh widget, and it is taken
|
||||||
|
;; off the page afterwards.
|
||||||
|
|
||||||
|
(defn- widget [] @@#'webmcp/widget)
|
||||||
|
|
||||||
|
(defn- tools-in-widget
|
||||||
|
"The names of the tools the WebMCP widget offers its client."
|
||||||
|
[]
|
||||||
|
(set (js/Array.from (.keys (.-availableTools ^js (widget))))))
|
||||||
|
|
||||||
|
(def ^:private real-log (.-log js/console))
|
||||||
|
|
||||||
|
(use-fixtures :each
|
||||||
|
tu/fake-server-fixture
|
||||||
|
tu/url-fixture
|
||||||
|
{:before (fn []
|
||||||
|
(reset! @#'webmcp/widget nil)
|
||||||
|
;; The widget logs every tool it registers. (Not console.log as a whole: that is
|
||||||
|
;; where the test report goes.)
|
||||||
|
(set! (.-log js/console)
|
||||||
|
(fn [& args]
|
||||||
|
(when-not (re-find #"^(Tool registered|Saved items)" (str (first args)))
|
||||||
|
(.apply real-log js/console (to-array args))))))
|
||||||
|
:after (fn []
|
||||||
|
(set! (.-log js/console) real-log)
|
||||||
|
(when-let [w (widget)]
|
||||||
|
(some-> (.getElementById js/document (.-elementId ^js w)) .remove))
|
||||||
|
(reset! @#'webmcp/widget nil)
|
||||||
|
(.clear js/sessionStorage)
|
||||||
|
(model-context/clear!)
|
||||||
|
;; init mounts the page in a root of its own.
|
||||||
|
(when-let [root @core/root]
|
||||||
|
(rdc/unmount root)
|
||||||
|
(reset! core/root nil)))})
|
||||||
|
|
||||||
|
(deftest init-test
|
||||||
|
(tu/log-in!)
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
(fn []
|
||||||
|
(core/init)
|
||||||
|
(is (= :unknown (get-in @rf-db/app-db [:user :status])) "it starts out not knowing who is logged in")
|
||||||
|
(tu/shows "the page is on the screen" "main h1" "tbd"))
|
||||||
|
(fn []
|
||||||
|
(is (some? (.getElementById js/document (.-elementId ^js (widget)))) "the WebMCP widget is on the page")
|
||||||
|
(is (= (set (map :name webmcp/tools)) (tools-in-widget)) "it offers the page's tools to the agent")
|
||||||
|
(is (= (map :name (remove :pages webmcp/tools)) (map :name (model-context/definitions :ai-setup)))
|
||||||
|
"and to the chatbot, the ones of the page it is on")
|
||||||
|
;; A tool, as the agent's client calls it.
|
||||||
|
(let [ping (.-execute (.get (.-availableTools ^js (widget)) "ping"))]
|
||||||
|
(-> (ping #js {})
|
||||||
|
(.then (fn [result] (is (= "pong" (.. result -content (at 0) -text))))))))
|
||||||
|
(fn []
|
||||||
|
(tu/eventually (fn [] (some #(= "Todos" (.-textContent %)) (tu/q-all "nav a")))))
|
||||||
|
(fn []
|
||||||
|
(is (= :logged-in (get-in @rf-db/app-db [:user :status])))
|
||||||
|
;; The hot-reload hook redraws and registers the tools again, without starting over.
|
||||||
|
(.clear (.-availableTools ^js (widget)))
|
||||||
|
(core/reload)
|
||||||
|
(is (= (set (map :name webmcp/tools)) (tools-in-widget)))
|
||||||
|
(tu/shows "still there after a reload" "main h1" "tbd")))
|
||||||
|
done)))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
|||||||
|
(ns tbd.ui.fetch-stream-test
|
||||||
|
"The ::fetch-stream effect (what the chat's replies come in through), run for real against fakes
|
||||||
|
of fetch that fail in the ways a network does."
|
||||||
|
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||||
|
[re-frame.core :as rf]
|
||||||
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.test-util :as tu]))
|
||||||
|
|
||||||
|
(use-fixtures :each tu/fake-server-fixture)
|
||||||
|
|
||||||
|
(def ^:private got (atom []))
|
||||||
|
|
||||||
|
(rf/reg-event-fx ::got-event (fn [_ [_ event]] (swap! got conj [:event event]) nil))
|
||||||
|
(rf/reg-event-fx ::got-response (fn [_ [_ response]] (swap! got conj [:response response]) nil))
|
||||||
|
|
||||||
|
(defn- bytes-of [text] (.encode (js/TextEncoder.) text))
|
||||||
|
|
||||||
|
(defn- reader
|
||||||
|
"A body reader that gives `reads` one at a time: bytes, or :fail for a read that fails. Then it's done."
|
||||||
|
[reads]
|
||||||
|
(let [reads (atom reads)]
|
||||||
|
#js {:read (fn []
|
||||||
|
(let [r (first @reads)]
|
||||||
|
(swap! reads rest)
|
||||||
|
(cond
|
||||||
|
(nil? r) (js/Promise.resolve #js {:done true})
|
||||||
|
(= :fail r) (js/Promise.reject (js/Error. "network error"))
|
||||||
|
:else (js/Promise.resolve #js {:done false :value r}))))}))
|
||||||
|
|
||||||
|
(defn- streaming
|
||||||
|
"A fetch that answers 200 with a body of `reads` (see `reader`), and notes what it was asked."
|
||||||
|
[asked & reads]
|
||||||
|
(fn [uri opts]
|
||||||
|
(reset! asked [uri (js->clj opts :keywordize-keys true)])
|
||||||
|
(js/Promise.resolve #js {:ok true :status 200 :body #js {:getReader (fn [] (reader reads))}})))
|
||||||
|
|
||||||
|
(defn- stream!
|
||||||
|
"Runs the effect against `fetch`, and gives a promise of what was dispatched once `n` things were
|
||||||
|
(and a moment more, for anything that shouldn't come)."
|
||||||
|
[fetch n]
|
||||||
|
(reset! got [])
|
||||||
|
(aset js/globalThis "fetch" fetch)
|
||||||
|
((tu/real-effect ::events/fetch-stream)
|
||||||
|
{:uri "/api/chat" :method "POST" :body {:x 1} :on-event [::got-event] :on-response [::got-response]})
|
||||||
|
(-> (tu/eventually #(>= (count @got) n))
|
||||||
|
(.then (fn [_] (js/Promise. (fn [resolve] (js/setTimeout #(resolve @got) 30)))))))
|
||||||
|
|
||||||
|
(defn- sse [& events]
|
||||||
|
(bytes-of (apply str (map #(str "data: " (js/JSON.stringify (clj->js %)) "\n\n") events))))
|
||||||
|
|
||||||
|
(deftest request-test
|
||||||
|
(async done
|
||||||
|
(let [asked (atom nil)]
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (streaming asked (sse {:type "RUN_FINISHED"})) 1)
|
||||||
|
(fn [_]
|
||||||
|
(is (= ["/api/chat" {:method "POST"
|
||||||
|
:headers {:Content-Type "application/edn"}
|
||||||
|
:body "{:x 1}"}]
|
||||||
|
@asked))))
|
||||||
|
done))))
|
||||||
|
|
||||||
|
(deftest events-test
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (streaming (atom nil)
|
||||||
|
(sse {:type "REASONING_MESSAGE_CONTENT" :delta "hm"} {:type "TEXT_MESSAGE_CONTENT" :delta "Hel"})
|
||||||
|
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "lo"} {:type "RUN_FINISHED"}))
|
||||||
|
4)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:event {:type "REASONING_MESSAGE_CONTENT" :delta "hm"}]
|
||||||
|
[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hel"}]
|
||||||
|
[:event {:type "TEXT_MESSAGE_CONTENT" :delta "lo"}]
|
||||||
|
[:event {:type "RUN_FINISHED"}]]
|
||||||
|
got)
|
||||||
|
"each event as it arrives, and nothing more when it ended properly")))
|
||||||
|
done)))
|
||||||
|
|
||||||
|
(deftest split-anywhere-test
|
||||||
|
;; The network cuts the stream wherever it likes: in a line, and in a multi-byte character too.
|
||||||
|
(async done
|
||||||
|
(let [bytes (sse {:type "TEXT_MESSAGE_CONTENT" :delta "héllo 👋 世界"} {:type "RUN_FINISHED"})
|
||||||
|
expected [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "héllo 👋 世界"}] [:event {:type "RUN_FINISHED"}]]]
|
||||||
|
(tu/finish
|
||||||
|
(apply tu/steps
|
||||||
|
(for [cut (range 1 (.-length bytes))]
|
||||||
|
(fn [_]
|
||||||
|
(-> (stream! (streaming (atom nil) (.subarray bytes 0 cut) (.subarray bytes cut)) 2)
|
||||||
|
(.then (fn [got] (is (= expected got) (str "cut at byte " cut))))))))
|
||||||
|
done))))
|
||||||
|
|
||||||
|
(deftest lost-connection-test
|
||||||
|
(testing "a stream that ends without saying it is over"
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (streaming (atom nil) (sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"})) 2)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
|
||||||
|
[:event {:type "RUN_ERROR" :message "the connection was lost"}]]
|
||||||
|
got))))
|
||||||
|
done)))
|
||||||
|
(testing "a read that fails in the middle of the stream"
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (streaming (atom nil) (sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}) :fail
|
||||||
|
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "never"}))
|
||||||
|
2)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
|
||||||
|
[:event {:type "RUN_ERROR" :message "the connection was lost"}]]
|
||||||
|
got))))
|
||||||
|
done)))
|
||||||
|
(testing "a stream that ended with an error of its own says so once"
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (streaming (atom nil)
|
||||||
|
(sse {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}
|
||||||
|
{:type "RUN_ERROR" :message "the LLM server took too long to answer"}))
|
||||||
|
2)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:event {:type "TEXT_MESSAGE_CONTENT" :delta "Hi"}]
|
||||||
|
[:event {:type "RUN_ERROR" :message "the LLM server took too long to answer"}]]
|
||||||
|
got))))
|
||||||
|
done))))
|
||||||
|
|
||||||
|
(deftest failed-request-test
|
||||||
|
(testing "a status that isn't 2xx: no stream, the response is given as ::fetch gives it"
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (fn [_ _] (js/Promise.resolve #js {:ok false :status 502
|
||||||
|
:text (fn [] (js/Promise.resolve "{:error \"down\"}"))}))
|
||||||
|
1)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:response {:ok? false :status 502 :body "{:error \"down\"}"}]] got))))
|
||||||
|
done)))
|
||||||
|
(testing "a request that fails outright"
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(stream! (fn [_ _] (js/Promise.reject (js/Error. "Failed to fetch"))) 1)
|
||||||
|
(fn [got]
|
||||||
|
(is (= [[:response {:ok? false :status nil :body "Failed to fetch"}]] got))))
|
||||||
|
done))))
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
(ns tbd.ui.model-context-test
|
||||||
|
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||||
|
[tbd.ui.model-context :as model-context]
|
||||||
|
[tbd.ui.test-util :as tu]))
|
||||||
|
|
||||||
|
(use-fixtures :each
|
||||||
|
{:before model-context/clear!
|
||||||
|
:after model-context/clear!})
|
||||||
|
|
||||||
|
(def ^:private schema {:type "object" :properties {} :required []})
|
||||||
|
|
||||||
|
(defn- tool [tool-name handler & {:as more}]
|
||||||
|
(merge {:name tool-name :description (str tool-name " tool") :input-schema schema :handler handler} more))
|
||||||
|
|
||||||
|
(deftest definitions-test
|
||||||
|
(testing "nothing registered, nothing offered"
|
||||||
|
(is (= [] (model-context/definitions :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 :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 :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 :tic-tac-toe)))))
|
||||||
|
(testing "tools of other names stay"
|
||||||
|
(model-context/register! [(tool "c" identity)])
|
||||||
|
(is (= ["a" "b" "c"] (map :name (model-context/definitions :tic-tac-toe)))))
|
||||||
|
(testing "clear! takes them all"
|
||||||
|
(model-context/clear!)
|
||||||
|
(is (= [] (model-context/definitions :tic-tac-toe)))))
|
||||||
|
|
||||||
|
(deftest pages-test
|
||||||
|
(model-context/register! [(tool "everywhere" identity)
|
||||||
|
(tool "tic-tac-toe-only" identity :pages #{:tic-tac-toe})
|
||||||
|
(tool "todos-only" identity :pages #{: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" "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 :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 "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! :tic-tac-toe tool-name arguments))
|
||||||
|
|
||||||
|
(deftest call-test
|
||||||
|
(let [seen (atom nil)]
|
||||||
|
(model-context/register!
|
||||||
|
[(tool "echo" (fn [args] (reset! seen args) (str "got " (pr-str args))))
|
||||||
|
(tool "data" (fn [_] {:a 1 :b [true nil]}))
|
||||||
|
(tool "nothing" (fn [_] nil))
|
||||||
|
(tool "later" (fn [_] (js/Promise.resolve "eventually")))
|
||||||
|
(tool "throws" (fn [_] (throw (js/Error. "boom"))))
|
||||||
|
(tool "rejects" (fn [_] (js/Promise.reject (js/Error. "no luck"))))
|
||||||
|
(tool "rejects-plainly" (fn [_] (js/Promise.reject "just a string")))])
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(called "echo" "{\"x\":1,\"name\":\"a b\",\"nested\":{\"y\":[1,2]}}")
|
||||||
|
(fn [r]
|
||||||
|
(testing "the arguments the LLM wrote reach the tool as a map with keyword keys"
|
||||||
|
(is (= {:x 1 :name "a b" :nested {:y [1 2]}} @seen))
|
||||||
|
(is (= {:text "got {:x 1, :name \"a b\", :nested {:y [1 2]}}" :error? false} r))))
|
||||||
|
#(called "echo" "")
|
||||||
|
(fn [r]
|
||||||
|
(testing "no arguments at all is an empty map: that is how a model calls a tool that takes none"
|
||||||
|
(is (= {} @seen))
|
||||||
|
(is (false? (:error? r)))))
|
||||||
|
#(called "echo" " ")
|
||||||
|
(fn [_] (is (= {} @seen) "blank too"))
|
||||||
|
#(called "echo" "{}")
|
||||||
|
(fn [_] (is (= {} @seen)))
|
||||||
|
#(called "data" "{}")
|
||||||
|
(fn [r]
|
||||||
|
(testing "data becomes JSON"
|
||||||
|
(is (= {:text "{\"a\":1,\"b\":[true,null]}" :error? false} r))))
|
||||||
|
#(called "nothing" "{}")
|
||||||
|
(fn [r] (is (= {:text "ok" :error? false} r) "a tool with nothing to say says ok"))
|
||||||
|
#(called "later" "{}")
|
||||||
|
(fn [r] (is (= {:text "eventually" :error? false} r) "a promise is waited for"))
|
||||||
|
#(called "throws" "{}")
|
||||||
|
(fn [r] (is (= {:text "boom" :error? true} r) "a tool that fails is an error result, not a failure"))
|
||||||
|
#(called "rejects" "{}")
|
||||||
|
(fn [r] (is (= {:text "no luck" :error? true} r)))
|
||||||
|
#(called "rejects-plainly" "{}")
|
||||||
|
(fn [r] (is (= {:text "just a string" :error? true} r))))
|
||||||
|
done))))
|
||||||
|
|
||||||
|
(deftest call-pages-test
|
||||||
|
(let [ran (atom [])]
|
||||||
|
(model-context/register! [(tool "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! :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 "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: tic-tac-toe-only (it is offered on the tic-tac-toe page)"
|
||||||
|
:error? true}
|
||||||
|
r))))
|
||||||
|
(fn [_] (is (= [:tic-tac-toe-only :everywhere] @ran) "and never run")))
|
||||||
|
done))))
|
||||||
|
|
||||||
|
(deftest call-failures-test
|
||||||
|
(let [ran? (atom false)]
|
||||||
|
(model-context/register! [(tool "t" (fn [_] (reset! ran? true) "ran"))])
|
||||||
|
(async done
|
||||||
|
(tu/finish
|
||||||
|
(tu/steps
|
||||||
|
#(called "nope" "{}")
|
||||||
|
(fn [r]
|
||||||
|
(testing "a tool that isn't there: the LLM may have made it up, and reads what it got wrong"
|
||||||
|
(is (= {:text "unknown tool: nope" :error? true} r))))
|
||||||
|
#(called "t" "{not json")
|
||||||
|
(fn [r]
|
||||||
|
(testing "arguments that are no JSON"
|
||||||
|
(is (= {:text "invalid arguments: they must be a JSON object" :error? true} r))))
|
||||||
|
#(called "t" "[1,2]")
|
||||||
|
(fn [r] (is (:error? r) "an array isn't arguments"))
|
||||||
|
#(called "t" "\"text\"")
|
||||||
|
(fn [r] (is (:error? r) "nor is a string"))
|
||||||
|
#(called "t" "null")
|
||||||
|
(fn [r] (is (:error? r) "nor is null"))
|
||||||
|
(fn [_] (is (false? @ran?) "the tool was never called with any of them")))
|
||||||
|
done))))
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
(ns tbd.ui.pages-test
|
||||||
|
(:require [cljs.test :refer [deftest is testing]]
|
||||||
|
[tbd.ui.pages :as pages]))
|
||||||
|
|
||||||
|
(deftest page->hash-test
|
||||||
|
(is (= "#/tic-tac-toe" (pages/page->hash :tic-tac-toe)))
|
||||||
|
(is (= "#/ai-setup" (pages/page->hash :ai-setup))))
|
||||||
|
|
||||||
|
(deftest hash->page-test
|
||||||
|
(testing "every page has an address that opens it"
|
||||||
|
(doseq [{:keys [id]} pages/pages]
|
||||||
|
(is (= id (pages/hash->page (pages/page->hash id))))))
|
||||||
|
(testing "with or without the #"
|
||||||
|
(is (= :todos (pages/hash->page "#/todos")))
|
||||||
|
(is (= :todos (pages/hash->page "/todos")))
|
||||||
|
(is (= :todos (pages/hash->page "todos"))))
|
||||||
|
(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 (= :tic-tac-toe pages/default-page))
|
||||||
|
(doseq [hash [nil "" "#" "#/" "#/nowhere" "#/Todos"]]
|
||||||
|
(is (= :tic-tac-toe (pages/hash->page hash)) (pr-str hash)))))
|
||||||
|
|
||||||
|
(deftest pages-test
|
||||||
|
(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)))))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
(ns tbd.ui.sse-test
|
||||||
|
(:require [cljs.test :refer [deftest is testing]]
|
||||||
|
[tbd.ui.sse :as sse]))
|
||||||
|
|
||||||
|
(deftest parse-test
|
||||||
|
(testing "complete events come out as maps, in order"
|
||||||
|
(is (= [[{:content "Hel"} {:content "lo"} {:done true}] ""]
|
||||||
|
(sse/parse "data: {\"content\":\"Hel\"}\n\ndata: {\"content\":\"lo\"}\n\ndata: {\"done\":true}\n\n"))))
|
||||||
|
(testing "an event that hasn't fully arrived is given back, to be put in front of the next piece"
|
||||||
|
(is (= [[{:content "a"}] "data: {\"content\":\"b"]
|
||||||
|
(sse/parse "data: {\"content\":\"a\"}\n\ndata: {\"content\":\"b")))
|
||||||
|
(is (= [[] "data: {\"content\":\"a\"}\n"]
|
||||||
|
(sse/parse "data: {\"content\":\"a\"}\n")) "the blank line that ends it hasn't come")
|
||||||
|
(is (= [[] ""] (sse/parse ""))))
|
||||||
|
(testing "pieces cut anywhere give the same events"
|
||||||
|
(let [text "data: {\"thinking\":\"hm\"}\n\ndata: {\"content\":\"h\\u00e9llo\\n\"}\n\ndata: {\"done\":true}\n\n"
|
||||||
|
expected [{:thinking "hm"} {:content "héllo\n"} {:done true}]]
|
||||||
|
(doseq [cut (range (inc (count text)))]
|
||||||
|
(let [[first-events pending] (sse/parse (subs text 0 cut))
|
||||||
|
[more-events pending] (sse/parse (str pending (subs text cut)))]
|
||||||
|
(is (= expected (into first-events more-events)) (str "cut at " cut))
|
||||||
|
(is (= "" pending))))))
|
||||||
|
(testing "a space after `data:` is optional, and a line break can be CRLF"
|
||||||
|
(is (= [[{:content "a"} {:content "b"}] ""]
|
||||||
|
(sse/parse "data:{\"content\":\"a\"}\r\n\r\ndata: {\"content\":\"b\"}\r\n\r\n"))))
|
||||||
|
(testing "events that carry no data, or no JSON, are skipped"
|
||||||
|
(is (= [[{:content "a"}] ""]
|
||||||
|
(sse/parse ": keep-alive\n\nevent: ping\n\ndata: not json\n\ndata: {\"content\":\"a\"}\n\n"))))
|
||||||
|
(testing "data over several lines is one JSON text"
|
||||||
|
(is (= [[{:content "a"}] ""]
|
||||||
|
(sse/parse "data: {\"content\":\ndata: \"a\"}\n\n")))))
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
(ns tbd.ui.subs-test
|
||||||
|
(:require [cljs.test :refer [deftest is testing use-fixtures]]
|
||||||
|
[re-frame.core :as rf]
|
||||||
|
[re-frame.db :as rf-db]
|
||||||
|
[re-frame.subs :as rf-subs]
|
||||||
|
[tbd.ui.db :as db]
|
||||||
|
[tbd.ui.subs :as subs]
|
||||||
|
[tbd.ui.tictactoe :as ttt]))
|
||||||
|
|
||||||
|
(use-fixtures :each
|
||||||
|
{:before #(do (rf/clear-subscription-cache!)
|
||||||
|
(reset! rf-db/app-db db/default-db))})
|
||||||
|
|
||||||
|
(defn- sub
|
||||||
|
"The value of a subscription. (Read outside of a component, which re-frame warns about.)"
|
||||||
|
[query]
|
||||||
|
(with-redefs [rf-subs/warn-when-not-reactive (fn [])]
|
||||||
|
@(rf/subscribe query)))
|
||||||
|
|
||||||
|
(defn- db! [f & args]
|
||||||
|
(apply swap! rf-db/app-db f args))
|
||||||
|
|
||||||
|
(deftest plain-subs-test
|
||||||
|
(testing "a new page"
|
||||||
|
(is (= "" (sub [::subs/operand :x])))
|
||||||
|
(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])))
|
||||||
|
(is (false? (sub [::subs/mcp-copied?]))))
|
||||||
|
(testing "they follow the db"
|
||||||
|
(db! assoc :page :todos)
|
||||||
|
(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 "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 "a1" :title "a"}] (:items (sub [::subs/todos]))))
|
||||||
|
(is (true? (sub [::subs/mcp-copied?])))))
|
||||||
|
|
||||||
|
(deftest ttt-game-test
|
||||||
|
(let [set-board! (fn [cells turn] (db! assoc :ttt {:board (mapv {\x :x \o :o} cells) :turn turn}))]
|
||||||
|
(testing "a new game: X to move, nobody has won"
|
||||||
|
(is (= {:board ttt/new-board :turn :x :outcome nil} (sub [::subs/ttt-game]))))
|
||||||
|
(testing "in progress"
|
||||||
|
(set-board! "x...o...." :x)
|
||||||
|
(is (= {:board [:x nil nil nil :o nil nil nil nil] :turn :x :outcome nil}
|
||||||
|
(sub [::subs/ttt-game]))))
|
||||||
|
(testing "a win, with the line"
|
||||||
|
(set-board! "xxxoo...." :o)
|
||||||
|
(is (= {:player :x :line [0 1 2]} (:outcome (sub [::subs/ttt-game])))))
|
||||||
|
(testing "a draw"
|
||||||
|
(set-board! "xoxxoooxx" :o)
|
||||||
|
(is (= :draw (:outcome (sub [::subs/ttt-game])))))))
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
(ns tbd.ui.test-util
|
||||||
|
"Shared fakes for the UI tests: the server the page talks to (a stand-in for `fetch`), a way
|
||||||
|
to stub re-frame effects, and helpers to drive the page in jsdom (see dom-setup.cjs)."
|
||||||
|
(:require [cljs.reader :as reader]
|
||||||
|
[cljs.test :as t :refer [is]]
|
||||||
|
[re-frame.registrar :as registrar]
|
||||||
|
[reagent.dom.client :as rdc]))
|
||||||
|
|
||||||
|
;; --- Telling dom-setup.cjs how the run is going (it fails a run that gets stuck or ends early) ---
|
||||||
|
|
||||||
|
(let [begin (get-method t/report [::t/default :begin-test-var])]
|
||||||
|
(defmethod t/report [::t/default :begin-test-var] [m]
|
||||||
|
(when begin (begin m))
|
||||||
|
(set! (.-uiTestsCurrent js/globalThis)
|
||||||
|
#js {:name (str (:name (meta (:var m)))) :since (js/Date.now)})))
|
||||||
|
|
||||||
|
(let [summary (get-method t/report [::t/default :summary])]
|
||||||
|
(defmethod t/report [::t/default :summary] [m]
|
||||||
|
(summary m)
|
||||||
|
(set! (.-uiTestsFinished js/globalThis) true)))
|
||||||
|
|
||||||
|
;; --- A fake server ---
|
||||||
|
|
||||||
|
(def server
|
||||||
|
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n, :add-error msg,
|
||||||
|
:chat-error msg, :chats [the conversations it was sent], :chat-requests [the whole requests],
|
||||||
|
:chat-scripts [..], :chat-script [..], :resume fn}. Its LLM says \"echo: \" and the last message,
|
||||||
|
streaming it in pieces. `:chat-script`, if set, is what it streams instead: events (maps, as the
|
||||||
|
real server sends them) and :pause, where the stream stops until the test calls the `:resume` fn
|
||||||
|
the server then holds. `:chat-scripts` are scripts for the next requests, one each, first
|
||||||
|
come first served (a conversation that goes back and forth, as when the LLM uses tools)."
|
||||||
|
(atom nil))
|
||||||
|
|
||||||
|
(defn 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)})
|
||||||
|
|
||||||
|
(defn- respond-stream
|
||||||
|
"A 200 whose body is the server-sent events of `script` (events and :pause)."
|
||||||
|
[script]
|
||||||
|
{:status 200 :script script})
|
||||||
|
|
||||||
|
(defn- next-chat-script!
|
||||||
|
"The next of the server's `:chat-scripts`, which it is then rid of, or nil."
|
||||||
|
[]
|
||||||
|
(when-let [script (first (:chat-scripts @server))]
|
||||||
|
(swap! server update :chat-scripts subvec 1)
|
||||||
|
script))
|
||||||
|
|
||||||
|
(defn- route [method uri body]
|
||||||
|
(let [{:keys [user todos]} @server
|
||||||
|
todo-id (some->> (re-find #"^/api/todos/([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)
|
||||||
|
(respond 200 {:logged-out true}))
|
||||||
|
(and (= "GET" method) (re-find #"^/api/add\?" uri))
|
||||||
|
(let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)]
|
||||||
|
(if-let [error (:add-error @server)]
|
||||||
|
(respond 400 {:error error :details {:x ["is wrong"]}})
|
||||||
|
(respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))})))
|
||||||
|
(not user) (respond 401 {:error "not logged in"})
|
||||||
|
(and (= "POST" method) (= "/api/chat" uri))
|
||||||
|
(if-let [error (:chat-error @server)]
|
||||||
|
(respond 502 {:error error})
|
||||||
|
(do (swap! server update :chats (fnil conj []) (:messages body))
|
||||||
|
(swap! server update :chat-requests (fnil conj []) body)
|
||||||
|
(respond-stream (or (next-chat-script!)
|
||||||
|
(:chat-script @server)
|
||||||
|
[{:thinking "Reading the message."}
|
||||||
|
{:content "echo: "}
|
||||||
|
{:content (:content (last (:messages body)))}
|
||||||
|
{:done true}]))))
|
||||||
|
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
|
||||||
|
(and (= "POST" method) (= "/api/todos" uri))
|
||||||
|
(if (seq (.trim (:title body)))
|
||||||
|
(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))
|
||||||
|
(respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}}))
|
||||||
|
(and (= "PATCH" method) todo-id)
|
||||||
|
(if-let [todo (first (filter #(= todo-id (:id %)) todos))]
|
||||||
|
(let [changed (merge todo body)]
|
||||||
|
(swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts)))
|
||||||
|
(respond 200 changed))
|
||||||
|
(respond 404 {:error "todo not found"}))
|
||||||
|
(and (= "DELETE" method) todo-id)
|
||||||
|
(do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts)))
|
||||||
|
(respond 200 {:deleted true}))
|
||||||
|
:else (respond 404 {:error "not found"}))))
|
||||||
|
|
||||||
|
(def ^:private piece-size
|
||||||
|
"How many bytes of a stream the fake server delivers at a time: few, so events are cut anywhere,
|
||||||
|
even in the middle of a character, as the network may."
|
||||||
|
7)
|
||||||
|
|
||||||
|
(defn- text? [x] (and (string? x) (seq x)))
|
||||||
|
|
||||||
|
(defn- tool-call-events [{:keys [id name arguments]}]
|
||||||
|
[{:type "TOOL_CALL_START" :toolCallId id :toolCallName name}
|
||||||
|
{:type "TOOL_CALL_ARGS" :toolCallId id :delta arguments}
|
||||||
|
{:type "TOOL_CALL_END" :toolCallId id}])
|
||||||
|
|
||||||
|
(defn- old->agui
|
||||||
|
"A script step, in the old shape tests write ({:thinking}/{:content}/{:tool-calls}/{:done}/
|
||||||
|
{:error}) as the AG-UI events (see tbd.chat.routes) it now corresponds to."
|
||||||
|
[{:keys [thinking content tool-calls done error]}]
|
||||||
|
(cond-> []
|
||||||
|
(text? thinking) (conj {:type "REASONING_MESSAGE_CONTENT" :delta thinking})
|
||||||
|
(text? content) (conj {:type "TEXT_MESSAGE_CONTENT" :delta content})
|
||||||
|
tool-calls (into (mapcat tool-call-events) tool-calls)
|
||||||
|
done (conj {:type "RUN_FINISHED"})
|
||||||
|
error (conj {:type "RUN_ERROR" :message error})))
|
||||||
|
|
||||||
|
(defn- script-items
|
||||||
|
"The script's events (in the old shape a test writes; see old->agui) as the pieces of bytes that
|
||||||
|
arrive, and :pause where the stream waits."
|
||||||
|
[script]
|
||||||
|
(mapcat (fn [step]
|
||||||
|
(if (= :pause step)
|
||||||
|
[:pause]
|
||||||
|
(mapcat (fn [event]
|
||||||
|
(let [bytes (.encode (js/TextEncoder.)
|
||||||
|
(str "data: " (js/JSON.stringify (clj->js event)) "\n\n"))]
|
||||||
|
(for [i (range 0 (.-length bytes) piece-size)]
|
||||||
|
(.subarray bytes i (min (.-length bytes) (+ i piece-size))))))
|
||||||
|
(old->agui step))))
|
||||||
|
script))
|
||||||
|
|
||||||
|
(defn- stream-reader
|
||||||
|
"What `response.body.getReader()` gives for a stream of `script`: `read` resolves with a piece
|
||||||
|
of bytes at a time, then with done. At a :pause it waits for the fn the server holds as :resume."
|
||||||
|
[script]
|
||||||
|
(let [items (atom (script-items script))]
|
||||||
|
(letfn [(read []
|
||||||
|
(let [item (first @items)]
|
||||||
|
(swap! items rest)
|
||||||
|
(cond
|
||||||
|
(nil? item) (js/Promise.resolve #js {:done true})
|
||||||
|
(= :pause item) (js/Promise. (fn [resolve]
|
||||||
|
(swap! server assoc :resume #(resolve (read)))))
|
||||||
|
:else (js/Promise.resolve #js {:done false :value item}))))]
|
||||||
|
#js {:read read})))
|
||||||
|
|
||||||
|
(defn fake-fetch [uri opts]
|
||||||
|
(let [opts (js->clj opts :keywordize-keys true)
|
||||||
|
{:keys [status body script]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
|
||||||
|
(js/Promise.resolve
|
||||||
|
#js {:ok (< status 300) :status status
|
||||||
|
:text (fn [] (js/Promise.resolve body))
|
||||||
|
:body #js {:getReader (fn [] (stream-reader script))}})))
|
||||||
|
|
||||||
|
(def ^:private real-fetch (.-fetch js/globalThis))
|
||||||
|
|
||||||
|
(def fake-server-fixture
|
||||||
|
"Each test starts with an empty server, nobody logged in, and `fetch` talking to it."
|
||||||
|
{:before (fn []
|
||||||
|
(reset! server {:user nil :todos [] :next-id 1})
|
||||||
|
(aset js/globalThis "fetch" fake-fetch))
|
||||||
|
:after (fn []
|
||||||
|
(aset js/globalThis "fetch" real-fetch))})
|
||||||
|
|
||||||
|
(def url-fixture
|
||||||
|
"Each test starts on the page's own address, with no fragment (the page shown is the first)."
|
||||||
|
(let [reset-hash! #(set! js/location.hash "")]
|
||||||
|
{:before reset-hash!
|
||||||
|
:after reset-hash!}))
|
||||||
|
|
||||||
|
(defn log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
|
||||||
|
|
||||||
|
;; --- Stubbing re-frame effects ---
|
||||||
|
|
||||||
|
(defn- set-effect!
|
||||||
|
"Installs `handler` for effect `id`. (Not reg-fx: that warns about every overwrite.)"
|
||||||
|
[id handler]
|
||||||
|
(swap! registrar/kind->id->handler assoc-in [:fx id] handler))
|
||||||
|
|
||||||
|
(def ^:private real-effects
|
||||||
|
"The effect handlers as the app registered them, before any test stubbed one."
|
||||||
|
(atom {}))
|
||||||
|
|
||||||
|
(defn real-effect
|
||||||
|
"The app's own handler for effect `id`, even while a test has it stubbed."
|
||||||
|
[id]
|
||||||
|
(or (get @real-effects id)
|
||||||
|
(let [handler (registrar/get-handler :fx id)]
|
||||||
|
(swap! real-effects assoc id handler)
|
||||||
|
handler)))
|
||||||
|
|
||||||
|
(defn stub-effects-fixture
|
||||||
|
"A fixture that replaces re-frame effects for the length of a test. `stubs` is a map of effect
|
||||||
|
id to a function of the effect's value, which is called instead of the real handler."
|
||||||
|
[stubs]
|
||||||
|
{:before (fn []
|
||||||
|
(doseq [[id handler] stubs]
|
||||||
|
(real-effect id)
|
||||||
|
(set-effect! id handler)))
|
||||||
|
:after (fn []
|
||||||
|
(doseq [id (keys stubs)]
|
||||||
|
(set-effect! id (real-effect id))))})
|
||||||
|
|
||||||
|
;; --- Driving the page ---
|
||||||
|
|
||||||
|
(defn- poll
|
||||||
|
"A promise of {:ok? bool :value v}: `(f)` as soon as it is truthy, or its last value after about
|
||||||
|
a second. The page updates asynchronously (re-frame queues events, React renders in the
|
||||||
|
background), so tests wait for what they expect to show up rather than for a fixed time."
|
||||||
|
[f]
|
||||||
|
(js/Promise.
|
||||||
|
(fn [resolve]
|
||||||
|
(let [start (js/Date.now)
|
||||||
|
check (fn check []
|
||||||
|
(let [v (f)]
|
||||||
|
(cond
|
||||||
|
v (resolve {:ok? true :value v})
|
||||||
|
(> (- (js/Date.now) start) 1000) (resolve {:ok? false :value v})
|
||||||
|
:else (js/setTimeout check 2))))]
|
||||||
|
(check)))))
|
||||||
|
|
||||||
|
(defn eventually
|
||||||
|
"A promise of `(f)`, once it is truthy. If it isn't within about a second the test fails (with
|
||||||
|
the place that waited), so a wait that can't succeed doesn't pass unnoticed."
|
||||||
|
[f]
|
||||||
|
(let [waited-at (js/Error. "timed out waiting for the page")]
|
||||||
|
(-> (poll f)
|
||||||
|
(.then (fn [{:keys [ok? value]}]
|
||||||
|
(when-not ok?
|
||||||
|
(t/do-report {:type :fail :message "timed out waiting for the page"
|
||||||
|
:expected "the condition to become true"
|
||||||
|
:actual (str "still " (pr-str value) "\n" (.-stack waited-at))}))
|
||||||
|
value)))))
|
||||||
|
|
||||||
|
(defn q
|
||||||
|
"The first element of the page matching CSS `selector`, or nil."
|
||||||
|
[selector]
|
||||||
|
(.querySelector js/document selector))
|
||||||
|
|
||||||
|
(defn q-all [selector]
|
||||||
|
(vec (.querySelectorAll js/document selector)))
|
||||||
|
|
||||||
|
(defn text
|
||||||
|
"The text of the first element matching `selector`, or nil when there is none."
|
||||||
|
[selector]
|
||||||
|
(some-> (q selector) .-textContent))
|
||||||
|
|
||||||
|
(defn shows
|
||||||
|
"A promise that checks that the text of `selector` becomes `expected` (`what` says what it is)."
|
||||||
|
[what selector expected]
|
||||||
|
(-> (poll #(= expected (text selector)))
|
||||||
|
(.then (fn [_] (is (= expected (text selector)) what)))))
|
||||||
|
|
||||||
|
(defn by-label
|
||||||
|
"The element whose aria-label is `label`."
|
||||||
|
[label]
|
||||||
|
(q (str "[aria-label=\"" label "\"]")))
|
||||||
|
|
||||||
|
(defn click! [el]
|
||||||
|
(.click ^js el))
|
||||||
|
|
||||||
|
(defn type!
|
||||||
|
"Types `value` into an input: sets it the way the browser does and fires `input`, which is
|
||||||
|
what React's onChange listens to."
|
||||||
|
[el value]
|
||||||
|
;; window.Event, not the global one: under Node that is Node's own, which jsdom rejects.
|
||||||
|
(let [setter (.-set (js/Object.getOwnPropertyDescriptor js/HTMLInputElement.prototype "value"))]
|
||||||
|
(.call setter el value)
|
||||||
|
(.dispatchEvent el (js/window.Event. "input" #js {:bubbles true}))))
|
||||||
|
|
||||||
|
(defn submit!
|
||||||
|
"Submits the form `el` (an event, not requestSubmit, so it doesn't matter what the browser
|
||||||
|
would validate)."
|
||||||
|
[el]
|
||||||
|
(.dispatchEvent el (js/window.Event. "submit" #js {:bubbles true :cancelable true})))
|
||||||
|
|
||||||
|
(defonce ^:private root (atom nil))
|
||||||
|
|
||||||
|
(defn mount!
|
||||||
|
"Renders `component` (hiccup) into a fresh #app, as core/init does, and returns a promise."
|
||||||
|
[component]
|
||||||
|
(let [container (.getElementById js/document "app")]
|
||||||
|
(reset! root (rdc/create-root container))
|
||||||
|
(rdc/render @root component)
|
||||||
|
(js/Promise.resolve)))
|
||||||
|
|
||||||
|
(defn unmount!
|
||||||
|
"Removes what `mount!` rendered."
|
||||||
|
[]
|
||||||
|
(when-let [r @root]
|
||||||
|
(rdc/unmount r)
|
||||||
|
(reset! root nil)))
|
||||||
|
|
||||||
|
(defn steps
|
||||||
|
"Runs `fs` one after the other, as a promise: each is called with the result of the previous
|
||||||
|
one, which may be a promise, and the promise resolves to the result of the last."
|
||||||
|
[& fs]
|
||||||
|
(reduce (fn [p f] (.then p f)) (js/Promise.resolve) fs))
|
||||||
|
|
||||||
|
(defn finish
|
||||||
|
"Ends an `async` test when the promise `p` settles; a rejection fails the test."
|
||||||
|
[p done]
|
||||||
|
(-> p
|
||||||
|
(.then (fn [_] (done)))
|
||||||
|
(.catch (fn [e]
|
||||||
|
(t/do-report {:type :error :message "Uncaught exception, not in assertion."
|
||||||
|
:expected nil :actual e})
|
||||||
|
(done)))))
|
||||||
|
|
||||||
|
(def page-fixture
|
||||||
|
"Leaves the page empty after each test."
|
||||||
|
{:after unmount!})
|
||||||
File diff suppressed because it is too large
Load Diff
+377
-87
@@ -1,76 +1,52 @@
|
|||||||
(ns tbd.ui.webmcp-test
|
(ns tbd.ui.webmcp-test
|
||||||
(:require [cljs.reader :as reader]
|
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||||
[cljs.test :refer [deftest is testing async use-fixtures]]
|
[clojure.string :as str]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[re-frame.db :as rf-db]
|
[re-frame.db :as rf-db]
|
||||||
|
[tbd.agent.prompt :as prompt]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.test-util :as tu]
|
||||||
[tbd.ui.webmcp :as webmcp]))
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
;; A fake browser: the page's server, and the WebMCP registry the tools end up in.
|
;; A fake browser: the page's server (tbd.ui.test-util), and the WebMCP registry the tools end up in.
|
||||||
|
|
||||||
(def ^:private server
|
|
||||||
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n}."
|
|
||||||
(atom nil))
|
|
||||||
|
|
||||||
(defn- respond [status body]
|
|
||||||
{:status status :body (pr-str body)})
|
|
||||||
|
|
||||||
(defn- route [method uri body]
|
|
||||||
(let [{:keys [user todos]} @server
|
|
||||||
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
|
|
||||||
(cond
|
|
||||||
(= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"}))
|
|
||||||
(and (= "GET" method) (re-find #"^/api/add\?" uri))
|
|
||||||
(let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)]
|
|
||||||
(if-let [error (:add-error @server)]
|
|
||||||
(respond 400 {:error error :details {:x ["is wrong"]}})
|
|
||||||
(respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))})))
|
|
||||||
(not user) (respond 401 {:error "not logged in"})
|
|
||||||
(and (= "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
|
|
||||||
:created-at 1 :updated-at 1}]
|
|
||||||
(swap! server #(-> % (update :todos conj todo) (update :next-id inc)))
|
|
||||||
(respond 201 todo))
|
|
||||||
(respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}}))
|
|
||||||
(and (= "PATCH" method) todo-id)
|
|
||||||
(if-let [todo (first (filter #(= todo-id (:id %)) todos))]
|
|
||||||
(let [changed (merge todo body)]
|
|
||||||
(swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts)))
|
|
||||||
(respond 200 changed))
|
|
||||||
(respond 404 {:error "todo not found"}))
|
|
||||||
(and (= "DELETE" method) todo-id)
|
|
||||||
(do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts)))
|
|
||||||
(respond 200 {:deleted true}))
|
|
||||||
:else (respond 404 {:error "not found"}))))
|
|
||||||
|
|
||||||
(defn- fake-fetch [uri opts]
|
|
||||||
(let [opts (js->clj opts :keywordize-keys true)
|
|
||||||
{:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
|
|
||||||
(js/Promise.resolve
|
|
||||||
#js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))})))
|
|
||||||
|
|
||||||
(def ^:private registered
|
(def ^:private registered
|
||||||
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
|
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
|
||||||
(atom {}))
|
(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
|
(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]
|
#js {:registerTool (fn [name description input-schema execute]
|
||||||
(swap! registered assoc name {:description description
|
(swap! registered assoc name {:description description
|
||||||
:input-schema input-schema
|
:input-schema input-schema
|
||||||
:execute execute}))})
|
:execute execute}))
|
||||||
|
:registerPrompt (fn [name description arguments execute]
|
||||||
(def ^:private real-fetch (.-fetch js/globalThis))
|
(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
|
(use-fixtures :each
|
||||||
|
tu/url-fixture
|
||||||
{:before (fn []
|
{:before (fn []
|
||||||
(reset! registered {})
|
(reset! registered {})
|
||||||
(reset! server {:user nil :todos [] :next-id 1})
|
(reset! registered-prompts {})
|
||||||
(aset js/globalThis "fetch" fake-fetch))
|
(reset! registered-resources {})
|
||||||
:after (fn []
|
((:before tu/fake-server-fixture)))
|
||||||
(aset js/globalThis "fetch" real-fetch))})
|
:after (:after tu/fake-server-fixture)})
|
||||||
|
|
||||||
(defn- call
|
(defn- call
|
||||||
"A promise of the result of the tool `tool-name` on `args`, as {:text :error?}."
|
"A promise of the result of the tool `tool-name` on `args`, as {:text :error?}."
|
||||||
@@ -95,8 +71,6 @@
|
|||||||
(rf/dispatch-sync [::events/initialize])
|
(rf/dispatch-sync [::events/initialize])
|
||||||
(webmcp/register-tools! fake-mcp))
|
(webmcp/register-tools! fake-mcp))
|
||||||
|
|
||||||
(defn- log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
|
|
||||||
|
|
||||||
(deftest registers-the-tools
|
(deftest registers-the-tools
|
||||||
(is (identical? fake-mcp (webmcp/register-tools! fake-mcp)))
|
(is (identical? fake-mcp (webmcp/register-tools! fake-mcp)))
|
||||||
(is (= (set (map :name webmcp/tools)) (set (keys @registered))))
|
(is (= (set (map :name webmcp/tools)) (set (keys @registered))))
|
||||||
@@ -107,7 +81,8 @@
|
|||||||
(is (fn? execute) tool-name))
|
(is (fn? execute) tool-name))
|
||||||
(testing "what the tools need, as the agent sees it"
|
(testing "what the tools need, as the agent sees it"
|
||||||
(is (= ["x" "y"] (vec (.. ^js (:input-schema (get @registered "add")) -required))))
|
(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
|
(deftest registering-again-replaces-the-tools
|
||||||
(webmcp/register-tools! fake-mcp)
|
(webmcp/register-tools! fake-mcp)
|
||||||
@@ -139,7 +114,7 @@
|
|||||||
(done))))))
|
(done))))))
|
||||||
|
|
||||||
(deftest add-shows-the-servers-error
|
(deftest add-shows-the-servers-error
|
||||||
(swap! server assoc :add-error "Invalid parameters")
|
(swap! tu/server assoc :add-error "Invalid parameters")
|
||||||
(open-page!)
|
(open-page!)
|
||||||
(async done
|
(async done
|
||||||
(-> (page-loaded)
|
(-> (page-loaded)
|
||||||
@@ -153,6 +128,33 @@
|
|||||||
(is (re-find #"must be numbers" (:text r)))
|
(is (re-find #"must be numbers" (:text r)))
|
||||||
(done))))))
|
(done))))))
|
||||||
|
|
||||||
|
(deftest login-state-unavailable
|
||||||
|
;; The server can't say who is logged in: the tools that need to know say so.
|
||||||
|
(aset js/globalThis "fetch"
|
||||||
|
(fn [_ _] (js/Promise.resolve #js {:ok false :status 500
|
||||||
|
:text (fn [] (js/Promise.resolve (pr-str {:error "Boom"})))})))
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (call "whoami" {})
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "could not tell who is logged in: Boom" :error? false} r))
|
||||||
|
(call "list_todos" {})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "could not tell who is logged in: Boom" :error? true} r))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest add-while-adding
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then (fn [_]
|
||||||
|
;; A person pressed Add a moment ago and the sum hasn't come back.
|
||||||
|
(rf/dispatch-sync [::events/add])
|
||||||
|
(call "add" {:x 1 :y 2})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "an addition is already in progress" :error? true} r))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
(deftest anonymous-user
|
(deftest anonymous-user
|
||||||
(open-page!)
|
(open-page!)
|
||||||
(async done
|
(async done
|
||||||
@@ -163,15 +165,191 @@
|
|||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(is (re-find #"not logged in" (:text 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]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(is (empty? (:todos @server)) "nothing was sent")
|
(is (empty? (:todos @tu/server)) "nothing was sent")
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
;; A tool that acts on the page opens the page it acts on, as a click on its tab would.
|
||||||
|
|
||||||
|
(defn- page [] (:page @rf-db/app-db))
|
||||||
|
|
||||||
|
(deftest tools-open-the-page-they-act-on
|
||||||
|
(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
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then (fn [_]
|
||||||
|
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||||
|
(call "get_tic_tac_toe" {})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :ai-setup (page)) "reading changes nothing")
|
||||||
|
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :tic-tac-toe (page)))
|
||||||
|
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||||
|
(call "new_tic_tac_toe_game" {})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :tic-tac-toe (page)))
|
||||||
|
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||||
|
(call "add" {:x 1 :y 2})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :calculator (page)) "the calculator is on its own page")
|
||||||
|
(call "list_todos" {})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :calculator (page)))
|
||||||
|
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||||
|
(.then (fn [_]
|
||||||
|
(is (= :todos (page)))
|
||||||
|
(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 :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
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then #(call "navigate" {:page "ai-setup"}))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "{\"page\":\"ai-setup\",\"title\":\"AI setup\"}" :error? false} r))
|
||||||
|
(is (= :ai-setup (page)) "the page is shown, as after a click on its tab")
|
||||||
|
(is (= "#/ai-setup" js/location.hash) "and the URL follows")
|
||||||
|
(call "navigate" {:page "tic-tac-toe"})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (false? (:error? r)))
|
||||||
|
(is (= :tic-tac-toe (page)))
|
||||||
|
(call "navigate" {:page "nowhere"})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? 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})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r) "the page is the name of one")
|
||||||
|
(call "navigate" {})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (= :ai-setup (page)) "a refused navigation leaves the page where it was")
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest navigate-to-a-page-that-needs-a-login
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then #(call "navigate" {:page "todos"}))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r) "the tab isn't there for someone who is not logged in")
|
||||||
|
(is (re-find #"not logged in" (:text r)))
|
||||||
|
(is (= :tic-tac-toe (page)))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest navigate-to-the-todos-when-logged-in
|
||||||
|
(tu/log-in!)
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then #(call "navigate" {:page "todos"}))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "{\"page\":\"todos\",\"title\":\"Todos\"}" :error? false} r))
|
||||||
|
(is (= :todos (page)))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest a-tool-that-fails-leaves-the-page-alone
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then (fn [_] (call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r) "not logged in")
|
||||||
|
(is (= :tic-tac-toe (page)) "so there is no todos page to go to")
|
||||||
(done))))))
|
(done))))))
|
||||||
|
|
||||||
(deftest todos
|
(deftest todos
|
||||||
(log-in!)
|
(tu/log-in!)
|
||||||
(swap! 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)
|
:next-id 2)
|
||||||
(open-page!)
|
(open-page!)
|
||||||
(async done
|
(async done
|
||||||
@@ -181,48 +359,49 @@
|
|||||||
(call "list_todos" {})))
|
(call "list_todos" {})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= ["old"] (map :title (json (:text 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]
|
(.then (fn [r]
|
||||||
(is (= "old" (:title (json (:text r)))))
|
(is (= ["old"] (map :title (json (:text r)))))
|
||||||
(call "get_todo" {:id 99})))
|
(call "get_todo" {:todos [{:id (tu/todo-uuid 99)}]})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= {:text "todo not found" :error? true} r))
|
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||||
(call "create_todo" {:title "buy milk"})))
|
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (false? (:error? 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.
|
;; 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 (= ["old" "buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||||
(is (= "" (get-in @rf-db/app-db [:todos :new-title])))
|
(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]
|
(.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])))))
|
(is (true? (:done (last (get-in @rf-db/app-db [:todos :items])))))
|
||||||
;; Already ticked: nothing to do, and no request.
|
;; Already ticked: nothing to do, and no request.
|
||||||
(swap! server assoc :todos [])
|
(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]
|
(.then (fn [r]
|
||||||
(is (true? (:done (json (:text r)))))
|
(is (= [true] (map :done (json (:text r)))))
|
||||||
(call "set_todo_done" {:id 2 :done "yes"})))
|
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(call "delete_todo" {:id 1})))
|
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||||
(.then (fn [r]
|
(.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]))))
|
(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]
|
(.then (fn [r]
|
||||||
(is (= {:text "todo not found" :error? true} r))
|
(is (= {:text (str "todo not found: " (tu/todo-uuid 1)) :error? true} r))
|
||||||
(call "get_todo" {:id 1.5})))
|
(call "get_todo" {:todos [{:id 42}]})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(done))))))
|
(done))))))
|
||||||
|
|
||||||
(deftest create-todo-shows-the-servers-error
|
(deftest create-todo-shows-the-servers-error
|
||||||
(log-in!)
|
(tu/log-in!)
|
||||||
(open-page!)
|
(open-page!)
|
||||||
(async done
|
(async done
|
||||||
(-> (call "create_todo" {:title " "})
|
(-> (call "create_todo" {:todos [{:title " "}]})
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(is (re-find #"Invalid body" (:text r)))
|
(is (re-find #"Invalid body" (:text r)))
|
||||||
@@ -236,24 +415,32 @@
|
|||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (false? (:error? r)))
|
(is (false? (:error? r)))
|
||||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||||
(call "move_tic_tac_toe" {:cell 4})))
|
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
|
||||||
(.then (fn [r]
|
(.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, and says it is O's turn now.
|
||||||
(is (= :x (get-in @rf-db/app-db [:ttt :board 4])))
|
(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"}
|
(is (= {:board [nil nil nil nil "x" nil nil nil nil] :status "in_progress" :turn "o"}
|
||||||
(json (:text r))))
|
(json (:text r))))
|
||||||
(call "move_tic_tac_toe" {:cell 4})))
|
(call "move_tic_tac_toe" {:cell 4 :player "o"})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= {:text "cell 4 is taken" :error? true} r))
|
(is (= {:text "cell 4 is taken" :error? true} r))
|
||||||
|
(is (= :x (get-in @rf-db/app-db [:ttt :board 4])) "the mark on the taken cell stays")
|
||||||
|
(is (= :o (get-in @rf-db/app-db [:ttt :turn])) "and the refused move does not use up the turn")
|
||||||
(call "move_tic_tac_toe" {:cell 0 :player "x"})))
|
(call "move_tic_tac_toe" {:cell 0 :player "x"})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= {:text "it is o's turn, not x's" :error? true} r))
|
(is (:error? r))
|
||||||
|
(is (re-find #"it is o's turn, not x's" (:text r)))
|
||||||
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
|
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
|
||||||
(call "move_tic_tac_toe" {:cell 9})))
|
(call "move_tic_tac_toe" {:cell 0})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r) "the side is required: no moving for whoever's turn it is")
|
||||||
|
(is (re-find #"player must be" (:text r)))
|
||||||
|
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
|
||||||
|
(call "move_tic_tac_toe" {:cell 9 :player "o"})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(is (re-find #"0 to 8" (:text r)))
|
(is (re-find #"0 to 8" (:text r)))
|
||||||
(call "move_tic_tac_toe" {:cell 1.5})))
|
(call "move_tic_tac_toe" {:cell 1.5 :player "o"})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (:error? r))
|
(is (:error? r))
|
||||||
(call "move_tic_tac_toe" {:cell 0 :player "z"})))
|
(call "move_tic_tac_toe" {:cell 0 :player "z"})))
|
||||||
@@ -270,10 +457,113 @@
|
|||||||
(is (= {:board ["o" "o" nil "x" "x" "x" nil nil nil]
|
(is (= {:board ["o" "o" nil "x" "x" "x" nil nil nil]
|
||||||
:status "won" :winner "x" :line [3 4 5]}
|
:status "won" :winner "x" :line [3 4 5]}
|
||||||
(json (:text r))))
|
(json (:text r))))
|
||||||
(call "move_tic_tac_toe" {:cell 8})))
|
(call "move_tic_tac_toe" {:cell 8 :player "o"})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= {:text "the game is over: start a new game" :error? true} r))
|
(is (= {:text "the game is over: start a new game" :error? true} r))
|
||||||
(call "new_tic_tac_toe_game" {})))
|
(call "new_tic_tac_toe_game" {})))
|
||||||
(.then (fn [r]
|
(.then (fn [r]
|
||||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||||
(done))))))
|
(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