Public: anyone can read this; the crew can change it.
The explorer's filters and ranking are the same words on the page, the API and the MCP `explore` tool #10409
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10387-the-explorer-s-filters-and-ranking-are-t → main 4 commits fork 4 threads · 4 unresolved
docs/features/explore.md +17 −1
| @@ -22,6 +22,22 @@ A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An a | ||
| 22 | 22 | |
| 23 | 23 | In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships (2). The weight is a constant in `explore.ts`, not one of the front page's admin weights. |
| 24 | 24 | |
| 25 | An unknown `sort:` word is ignored with a note naming the sorts above. | |
| 25 | An unknown `sort:` word is a 422 naming the sorts above (see the end of this page). | |
| 26 | 26 | |
| 27 | 27 | When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the whole line with that word mended. Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up. |
| 28 | ||
| 29 | ## One word on every surface (#10387) | |
| 30 | ||
| 31 | What a person filters on `/explore` an agent filters with the same words: the `q` line. The page's chips are plain links to `/explore?q=<line>`; the API takes the same line as `?q=`, the MCP tool as the argument `q`. | |
| 32 | ||
| 33 | | the page offers | the line | API `GET /api/v1/explore` | MCP `explore` | | |
| 34 | |---|---|---|---| | |
| 35 | | Best / Forks / Shipped / Liked / Use / Fresh | `sort:score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?q=sort:forks` | `{ q: "sort:forks" }` | | |
| 36 | | All / Apps / People / Forges (admin) | `kind:app` `kind:person` `kind:forge` | `?q=kind:app` | `{ q: "kind:app" }` | | |
| 37 | | Any time / Today / This week / This month | `updated:1d` `7d` `30d` | `?q=updated:7d` | `{ q: "updated:7d" }` | | |
| 38 | | Show archived | `archived:yes` | `?q=archived:yes` | `{ q: "archived:yes" }` | | |
| 39 | | (owner link on a row) | `owner:name` | `?q=owner:name` | `{ q: "owner:name" }` | | |
| 40 | ||
| 41 | The three return the same ordered rows (`test/explore-parity.test.ts`). | |
| 42 | ||
| 43 | An unknown filter word is refused the same way everywhere: `422` from the API (`{ "error": "unknown_filter", "message": "sort:bogus is not a sort — use score, forks, …", "suggest"? }`), an `unknown_filter` error from the MCP tool, and `422` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595). |
src/http/api.ts +2
| @@ -3375,6 +3375,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3375 | 3375 | const line = ctx.url.searchParams.get("q") ?? ""; |
| 3376 | 3376 | const { query, unknown } = readExploreLine(line); |
| 3377 | 3377 | const suggest = exploreSuggestion(line); |
| 3378 | if (unknown.length > 0) | |
| 3379 | return json({ error: "unknown_filter", message: exploreNote(unknown), ...(suggest === undefined ? {} : { suggest: suggest.line }) }, 422); | |
| 3378 | 3380 | const mine = runRepos(ctx.app, ctx.auth); |
| 3379 | 3381 | // A run token is shown only the rows of its own repositories; the cut |
| 3380 | 3382 | // and the cursor are made from those rows alone. |
src/http/mcp/tools/explore.ts +2 −3
| @@ -12,7 +12,7 @@ export const tool: Tool = { | ||
| 12 | 12 | q: { |
| 13 | 13 | type: "string", |
| 14 | 14 | maxLength: 200, |
| 15 | description: "Words; kind: sort: updated:7d owner: archived:", | |
| 15 | description: "kind: sort: updated:7d owner: archived:. Unknown word: error", | |
| 16 | 16 | }, |
| 17 | 17 | query: { |
| 18 | 18 | type: "string", |
| @@ -30,13 +30,12 @@ export const tool: Tool = { | ||
| 30 | 30 | const line = q !== "" ? q : str(args["query"]); |
| 31 | 31 | const { query, unknown } = readExploreLine(line); |
| 32 | 32 | const suggest = exploreSuggestion(line); |
| 33 | if (unknown.length > 0) return bad("unknown_filter", exploreNote(unknown) + (suggest === undefined ? "" : `; did you mean "${suggest.line}"?`)); | |
| 33 | 34 | const cursor = str(args["cursor"]); |
| 34 | 35 | const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor); |
| 35 | 36 | if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before"); |
| 36 | 37 | return good({ |
| 37 | 38 | query: exploreLine(query), |
| 38 | ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), | |
| 39 | ...(suggest === undefined ? {} : { suggest: suggest.line }), | |
| 40 | 39 | items: page.items, |
| 41 | 40 | next_cursor: page.nextCursor, |
| 42 | 41 | }); |
src/web/studio/explore.ts +3 −1
| @@ -218,7 +218,9 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 218 | 218 | ${rows.length === 0 |
| 219 | 219 | ? emptyState(ctx, query) |
| 220 | 220 | : card(html`<ul class="list">${rows.map(rowHtml)}</ul>`, { pad: false, extra: "bare" })}</div>`; |
| 221 | const response = page({ ctx, title: "Explore", body }); | |
| 221 | const drawn = page({ ctx, title: "Explore", body }); | |
| 222 | // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results. | |
| 223 | const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers }); | |
| 222 | 224 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 223 | 225 | return response; |
| 224 | 226 | } |
test/explore-parity.test.ts added +209
| @@ -0,0 +1,209 @@ | ||
| 1 | /** One filter, one word: the page, the API and the MCP `explore` tool return the same ordered ids (#10387). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | import { promote } from "./helpers/members.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let token: string; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dir = await mkdtemp(join(tmpdir(), "forge-explore-parity-")); | |
| 26 | app = createApp({ dataDir: dir, port: 0 }); | |
| 27 | for (const name of ["olive", "pine", "birch"]) { | |
| 28 | const made = await createUser(app.db, { origin: "seed", username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: false }); | |
| 29 | if (!made.ok) throw new Error(made.error); | |
outdated
The fixture has no forks, ships or likes, so sort:forks, ships, likes, use and fresh all tie and fall back to the same order. A broken sort passes. Seed signals that make each sort differ and assert the distinct expected order for each. |
||
| 30 | proveAll(app); | |
| 31 | promote(app.db, made.value); | |
| 32 | if (name === "olive") token = createToken(app.db, made.value.id, "ro", "ro").token; | |
| 33 | for (const r of ["garden", "kiln", "loom"]) { | |
| 34 | const repo = await createRepo(app, made.value, { name: `${r}-${name}`, visibility: "public" }); | |
| 35 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 36 | const rec = insertApp(app.db, { repoId: repo.value.id, slug: `${name}-${r}`, access: "public" }); | |
| 37 | if (!rec.ok) throw new Error(rec.error); | |
| 38 | } | |
| 39 | } | |
| 40 | seedSignals(); | |
| 41 | servers = await serve(app); | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | await servers.stop(); | |
| 45 | app.db.close(); | |
| 46 | await rm(dir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | const DAY = 86_400_000; | |
| 50 | ||
| 51 | /** Signals that make every sort a different order: who is forked, shipped, liked, awake, fresh, and archived. */ | |
| 52 | function seedSignals(): void { | |
| 53 | const repoId = (owner: string, name: string): string => { | |
| 54 | const row = app.db.query<{ id: string }, [string, string]>("SELECT repo.id AS id FROM repo JOIN user ON user.id = repo.owner_id WHERE user.username = ?1 AND repo.name = ?2").get(owner, name); | |
| 55 | if (row === null) throw new Error(`no ${owner}/${name}`); | |
| 56 | return row.id; | |
| 57 | }; | |
| 58 | const owner = app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1").get("olive")!.id; | |
| 59 | const now = Date.now(); | |
| 60 | // forks: loom-olive 2, garden-olive 1 | |
| 61 | const fork = (child: [string, string], parent: [string, string]) => | |
| 62 | app.db.run("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2", [repoId(...parent), repoId(...child)]); | |
| 63 | fork(["pine", "kiln-pine"], ["olive", "loom-olive"]); | |
| 64 | fork(["pine", "garden-pine"], ["olive", "loom-olive"]); | |
| 65 | fork(["birch", "kiln-birch"], ["olive", "garden-olive"]); | |
| 66 | // ships (merged pulls): garden-pine 3 (latest 5d ago), loom-birch 2 (latest 1d ago), kiln-olive 1 (45d ago, outside the window) | |
| 67 | const ship = (r: [string, string], ago: number) => { | |
| 68 | const issue = createIssue(app.db, { repoId: repoId(...r), authorId: owner, title: "change", kind: "pull" }); | |
| 69 | if (!issue.ok) throw new Error(issue.error); | |
| 70 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'b', '', '', ?2)", [issue.value.id, new Date(now - ago).toISOString()]); | |
| 71 | }; | |
| 72 | ship(["pine", "garden-pine"], 5 * DAY); | |
| 73 | ship(["pine", "garden-pine"], 6 * DAY); | |
| 74 | ship(["pine", "garden-pine"], 7 * DAY); | |
| 75 | ship(["birch", "loom-birch"], 1 * DAY); | |
| 76 | ship(["birch", "loom-birch"], 2 * DAY); | |
| 77 | ship(["olive", "kiln-olive"], 45 * DAY); | |
| 78 | // likes: garden-birch 3, kiln-olive 2, loom-pine 1 | |
| 79 | const like = (r: [string, string], n: number) => { | |
| 80 | for (let i = 0; i < n; i++) app.db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [owner, `${r[1]}-${i}`, repoId(...r)]); | |
| 81 | }; | |
| 82 | like(["birch", "garden-birch"], 3); | |
| 83 | like(["olive", "kiln-olive"], 2); | |
| 84 | like(["pine", "loom-pine"], 1); | |
| 85 | // use (hours awake): kiln-pine 10, garden-olive 5, kiln-birch 2 | |
| 86 | const awake = (r: [string, string], hours: number) => | |
outdated
|
||
| 87 | app.db.run("INSERT INTO meter (repo_id, env, placement, started_at, beat_at, ended_at) VALUES (?1, 'production', 'owned', ?2, ?3, ?3)", [ | |
| 88 | repoId(...r), | |
| 89 | new Date(now - 2 * DAY - hours * 3_600_000).toISOString(), | |
| 90 | new Date(now - 2 * DAY).toISOString(), | |
| 91 | ]); | |
| 92 | awake(["pine", "kiln-pine"], 10); | |
| 93 | awake(["olive", "garden-olive"], 5); | |
| 94 | awake(["birch", "kiln-birch"], 2); | |
| 95 | // put away: loom-pine is only in archived:yes | |
| 96 | app.db.run("UPDATE repo SET archived_at = ?1 WHERE id = ?2", [new Date(now).toISOString(), repoId("pine", "loom-pine")]); | |
| 97 | } | |
| 98 | ||
| 99 | const LINES = [ | |
| 100 | "", | |
| 101 | "kind:app", | |
| 102 | "kind:person", | |
| 103 | "sort:forks", | |
| 104 | "sort:ships", | |
| 105 | "sort:likes", | |
| 106 | "sort:use", | |
| 107 | "sort:fresh", | |
| 108 | "sort:name", | |
| 109 | "updated:7d", | |
| 110 | "owner:pine", | |
| 111 | "archived:yes", | |
| 112 | "kind:app sort:name garden", | |
| 113 | ]; | |
| 114 | ||
| 115 | /** The row links inside the page's `<ul class="list">`, in order: each row's title is the one link to its own url. */ | |
| 116 | function pageOrder(html: string): string[] { | |
| 117 | const list = /<ul class="list">([\s\S]*?)<\/ul>/.exec(html); | |
| 118 | if (list === null) return []; | |
| 119 | return [...list[1]!.matchAll(/<li[^>]*>[\s\S]*?<a href="(\/[^"?]*)">[^<]*<\/a>/g)].map((m) => m[1]!); | |
| 120 | } | |
| 121 | ||
| 122 | const ids = (items: { kind: string; name: string }[]) => items.map((i) => `${i.kind}:${i.name}`); | |
| 123 | ||
| 124 | async function mcp(line: string): Promise<{ ok: boolean; text: string }> { | |
| 125 | const res = await fetch(`${servers.url}/mcp`, { | |
| 126 | method: "POST", | |
| 127 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 128 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { q: line } } }), | |
| 129 | }); | |
| 130 | const out = ((await res.json()) as any).result; | |
| 131 | return { ok: out.isError !== true, text: out.content[0].text }; | |
| 132 | } | |
| 133 | ||
| 134 | describe("one filter, three surfaces", () => { | |
| 135 | // The orders the seeded signals must give, so a sort that ignores its signal (and falls back to name) fails. | |
| 136 | const EXPECTED: Record<string, string[]> = { | |
| 137 | "kind:app sort:forks": ["olive/loom-olive", "olive/garden-olive"], | |
| 138 | "kind:app sort:ships": ["pine/garden-pine", "birch/loom-birch"], | |
| 139 | "kind:app sort:likes": ["birch/garden-birch", "olive/kiln-olive"], | |
| 140 | "kind:app sort:use": ["pine/kiln-pine", "olive/garden-olive", "birch/kiln-birch"], | |
| 141 | "kind:app sort:fresh": ["birch/loom-birch", "pine/garden-pine", "olive/kiln-olive"], | |
| 142 | "kind:app sort:name": ["birch/garden-birch", "birch/kiln-birch", "birch/loom-birch", "olive/garden-olive", "olive/kiln-olive", "olive/loom-olive", "pine/garden-pine", "pine/kiln-pine"], | |
| 143 | "kind:person sort:forks": ["olive"], | |
| 144 | "kind:app updated:7d": ["pine/garden-pine", "birch/loom-birch"], | |
| 145 | "kind:app archived:yes sort:name": ["birch/garden-birch", "birch/kiln-birch", "birch/loom-birch", "olive/garden-olive", "olive/kiln-olive", "olive/loom-olive", "pine/garden-pine", "pine/kiln-pine", "pine/loom-pine"], | |
| 146 | }; | |
| 147 | for (const [line, want] of Object.entries(EXPECTED)) { | |
| 148 | test(`"${line}" puts the seeded signals in their order, the same on the API, the MCP and the page`, async () => { | |
| 149 | const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).json()) as { items: { name: string; url: string }[] }; | |
| 150 | // Only the leading rows are pinned: the rest tie at zero and fall to name. | |
| 151 | expect(api.items.map((i) => i.name).slice(0, want.length)).toEqual(want); | |
| 152 | const tool = await mcp(line); | |
| 153 | expect(tool.ok).toBe(true); | |
| 154 | expect((JSON.parse(tool.text) as typeof api).items.map((i) => i.name)).toEqual(api.items.map((i) => i.name)); | |
| 155 | const html = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent(line)}`)).text(); | |
| 156 | expect(pageOrder(html)).toEqual(api.items.map((i) => i.url)); | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | test("the sorts are different orders, not one order under seven names", async () => { | |
| 161 | const seen = new Set<string>(); | |
| 162 | for (const sort of ["score", "forks", "ships", "likes", "use", "fresh", "name"]) { | |
| 163 | const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(`kind:app sort:${sort}`)}`)).json()) as { items: { name: string }[] }; | |
| 164 | seen.add(api.items.map((i) => i.name).join(",")); | |
| 165 | } | |
| 166 | expect(seen.size).toBe(7); | |
| 167 | }); | |
| 168 | ||
| 169 | for (const line of LINES) { | |
| 170 | test(`"${line}" gives the same ordered rows on the API and the MCP, and the page lists them in that order`, async () => { | |
| 171 | const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`, { headers: { authorization: `Bearer ${token}` } })).json()) as { items: { kind: string; name: string; url: string }[] }; | |
| 172 | const tool = await mcp(line); | |
| 173 | expect(tool.ok).toBe(true); | |
| 174 | const viaMcp = JSON.parse(tool.text) as { items: { kind: string; name: string; url: string }[] }; | |
| 175 | expect(ids(viaMcp.items)).toEqual(ids(api.items)); | |
| 176 | if (line === "") expect(api.items.length).toBeGreaterThan(0); | |
| 177 | // The page, anonymous, sees the same public rows; its order is the API's order. | |
| 178 | const anon = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).json()) as { items: { kind: string; name: string; url: string }[] }; | |
| 179 | const html = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent(line)}`)).text(); | |
| 180 | expect(pageOrder(html)).toEqual(anon.items.map((i) => i.url)); | |
| 181 | }); | |
| 182 | } | |
| 183 | ||
| 184 | test("the page's chips are links whose q is a line the API takes", async () => { | |
| 185 | const html = await (await fetch(`${servers.url}/explore`)).text(); | |
| 186 | for (const line of ["kind:app", "sort:forks", "sort:ships", "sort:likes", "sort:use", "sort:fresh", "updated:7d", "kind:person"]) { | |
| 187 | expect(html).toContain(`href="/explore?q=${encodeURIComponent(line)}"`); | |
| 188 | expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).status).toBe(200); | |
| 189 | } | |
| 190 | }); | |
| 191 | ||
| 192 | test("an unknown filter is a 422 naming the allowed words, on all three", async () => { | |
| 193 | const q = encodeURIComponent("sort:bogus"); | |
| 194 | const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`); | |
| 195 | expect(api.status).toBe(422); | |
| 196 | const body = (await api.json()) as { error: string; message: string }; | |
| 197 | expect(body.error).toBe("unknown_filter"); | |
| 198 | expect(body.message).toContain("use score, forks, ships, likes, use, fresh, name"); | |
| 199 | const tool = await mcp("sort:bogus"); | |
| 200 | expect(tool.ok).toBe(false); | |
| 201 | expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name"); | |
| 202 | const page = await fetch(`${servers.url}/explore?q=${q}`); | |
| 203 | expect(page.status).toBe(422); | |
| 204 | expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name"); | |
| 205 | for (const bad of ["kind:x", "updated:2y", "archived:maybe"]) { | |
| 206 | expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(bad)}`)).status).toBe(422); | |
| 207 | } | |
| 208 | }); | |
| 209 | }); |
test/explore-unknown-words.test.ts +24 −19
| @@ -62,9 +62,10 @@ describe("readExploreLine", () => { | ||
| 62 | 62 | const s = await serve(a); |
| 63 | 63 | try { |
| 64 | 64 | const q = encodeURIComponent("owner:trevato updated:2y archived:maybe"); |
| 65 | const body = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { note?: string; query: string }; | |
| 66 | expect(body.note).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no"); | |
| 67 | expect(body.query).toBe("owner:trevato"); | |
| 65 | const res = await fetch(`${s.url}/api/v1/explore?q=${q}`); | |
| 66 | expect(res.status).toBe(422); | |
| 67 | const body = (await res.json()) as { message: string }; | |
| 68 | expect(body.message).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no"); | |
| 68 | 69 | } finally { |
| 69 | 70 | await s.stop(); |
| 70 | 71 | a.db.close(); |
| @@ -127,15 +128,14 @@ describe("the page and the API", () => { | ||
| 127 | 128 | expect(out).not.toContain("<b>"); |
| 128 | 129 | }); |
| 129 | 130 | |
| 130 | test("the API answers a note in a field, not an error", async () => { | |
| 131 | test("the API answers 422 naming the allowed words (#10387)", async () => { | |
| 131 | 132 | const res = await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent("kind:nope")}`); |
| 132 | expect(res.status).toBe(200); | |
| 133 | const body = (await res.json()) as { note?: string; query: string; items: unknown[] }; | |
| 134 | expect(body.note).toContain("kind:nope is not a kind"); | |
| 135 | expect(body.query).toBe(""); | |
| 136 | expect(Array.isArray(body.items)).toBe(true); | |
| 137 | const fine = (await (await fetch(`${servers.url}/api/v1/explore?q=kind:app`)).json()) as { note?: string }; | |
| 138 | expect(fine.note).toBeUndefined(); | |
| 133 | expect(res.status).toBe(422); | |
| 134 | const body = (await res.json()) as { error: string; message: string }; | |
| 135 | expect(body.error).toBe("unknown_filter"); | |
| 136 | expect(body.message).toContain("kind:nope is not a kind — use app, person, forge"); | |
| 137 | const fine = await fetch(`${servers.url}/api/v1/explore?q=kind:app`); | |
| 138 | expect(fine.status).toBe(200); | |
| 139 | 139 | }); |
| 140 | 140 | |
| 141 | 141 | test("the MCP tool answers the same note", async () => { |
| @@ -152,13 +152,18 @@ describe("the page and the API", () => { | ||
| 152 | 152 | }, |
| 153 | 153 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { query } } }), |
| 154 | 154 | }); |
| 155 | return JSON.parse(((await res.json()) as any).result.content[0].text) as { note?: string }; | |
| 155 | const out = ((await res.json()) as any).result as { isError?: boolean; content: { text: string }[] }; | |
outdated
This helper guesses an error from |
||
| 156 | return { isError: out.isError === true, text: out.content[0]!.text }; | |
| 156 | 157 | }; |
| 157 | expect((await call("sort:bogus")).note).toContain("sort:bogus is not a sort"); | |
| 158 | expect((await call("sort:forks")).note).toBeUndefined(); | |
| 159 | expect((await call("archived:maybe")).note).toBe("archived:maybe is not an answer — use yes, no"); | |
| 160 | expect((await call("updated:2y")).note).toBe("updated:2y is not a window — use 1d, 7d, 30d"); | |
| 161 | expect((await call("owner:a/b")).note).toBe("owner:a/b is not a name"); | |
| 158 | const sort = await call("sort:bogus"); | |
| 159 | expect(sort.isError).toBe(true); | |
| 160 | expect(sort.text).toContain("unknown_filter: sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name"); | |
| 161 | const fine = await call("sort:forks"); | |
| 162 | expect(fine.isError).toBe(false); | |
| 163 | expect(JSON.parse(fine.text)).toHaveProperty("items"); | |
| 164 | expect(await call("archived:maybe")).toEqual({ isError: true, text: "unknown_filter: archived:maybe is not an answer — use yes, no" }); | |
| 165 | expect(await call("updated:2y")).toEqual({ isError: true, text: "unknown_filter: updated:2y is not a window — use 1d, 7d, 30d" }); | |
| 166 | expect(await call("owner:a/b")).toEqual({ isError: true, text: "unknown_filter: owner:a/b is not a name" }); | |
| 162 | 167 | }); |
| 163 | 168 | }); |
| 164 | 169 | |
| @@ -203,9 +208,9 @@ describe("did you mean", () => { | ||
| 203 | 208 | const { tool } = await import("../src/http/mcp/tools/explore.ts"); |
| 204 | 209 | const out = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:froks" }); |
| 205 | 210 | expect(JSON.stringify(out)).toContain("garden sort:forks"); |
| 206 | expect(JSON.stringify(out)).toContain("suggest"); | |
| 211 | expect(JSON.stringify(out)).toContain("did you mean"); | |
| 207 | 212 | const quiet = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:forks" }); |
| 208 | expect(JSON.stringify(quiet)).not.toContain("suggest"); | |
| 213 | expect(JSON.stringify(quiet)).not.toContain("did you mean"); | |
| 209 | 214 | const far = await tool.run({ app: a } as never, undefined as never, { q: "sort:zzzzzzzz" }); |
| 210 | 215 | expect(JSON.stringify(far)).not.toContain("suggest"); |
| 211 | 216 | expect(JSON.stringify(far)).toContain("is not a sort"); |
test/explore.test.ts +1 −1
| @@ -265,7 +265,7 @@ describe("explore", () => { | ||
| 265 | 265 | expect(names((await api("kind:app owner:bob")).items)).toEqual(["bob/busy"]); |
| 266 | 266 | expect(names((await api("owner:bob")).items).sort()).toEqual(["bob", "bob/busy"]); |
| 267 | 267 | const bad = await api("kind:app owner:.."); |
| 268 | expect((bad as { note?: string }).note).toBe("owner:.. is not a name"); | |
| 268 | expect((bad as { message?: string }).message).toBe("owner:.. is not a name"); | |
| 269 | 269 | const page = await (await fetch(`${base}/explore?q=kind:person`)).text(); |
| 270 | 270 | expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`); |
| 271 | 271 | }); |
The
qdescription in the tool schema still lists the words without saying an unknown one now fails. Agents read that schema, so say it errors withunknown_filterand names the allowed words.