diff --git a/app/package.json b/app/package.json index 98bca36a9..5b04aa78b 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "motion": "^13.1.0", "prompt-area": "^0.6.3", "react": "^19.2.0", diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 3d8d72879..80b7a20cb 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -3,7 +3,7 @@ import { useRenderActivityMessage, useRenderToolCall, } from "@copilotkit/react-core/v2"; -import { IconBox } from "@tabler/icons-react"; +import { IconBox, IconClock } from "@tabler/icons-react"; import { motion, useReducedMotion } from "motion/react"; import { memo, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { Streamdown } from "streamdown"; @@ -23,6 +23,7 @@ import { useMessageScroller, } from "@/components/ui/message-scroller"; import { Skeleton } from "@/components/ui/skeleton"; +import { readFiring } from "@/lib/channels/routine-firing"; import { markdownComponents } from "@/lib/markdown"; import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { readToolName } from "@/lib/plugins/tool-name"; @@ -380,6 +381,32 @@ function Arriving({ ); } +/** + * A turn a schedule asked for, drawn as the event it is. + * + * The frame around a firing is addressed to the model — see `shared/routine-firing.ts` — and it + * reached the transcript wearing `role: "user"`, which drew it as a muted bubble on the right, in + * the exact style of something the person typed. Somebody reading back through a channel found + * three sentences of instructions to a model in their own voice, telling their Bot what it may not + * do. For a product whose whole claim is that a Bot is a coworker you can hold to account, a record + * that misattributes who said what is the one thing it cannot afford. + * + * So: start-aligned and muted, because this is not the person speaking; the clock, because that is + * what a routine already is everywhere else in the app; and the instruction ALONE, because that is + * the part a person wrote and the only part addressed to them. + */ +function RoutineFiring({ instruction }: { instruction: string }) { + return ( +
+ + + Routine ran.{" "} + {instruction} + +
+ ); +} + /** * One drawn message, and it is memoised on PRIMITIVES ON PURPOSE. * @@ -405,6 +432,23 @@ const TranscriptMessage = memo(function TranscriptMessage({ text: string; }) { const isUser = role === "user"; + /* + * Checked before anything else a person's message gets. A firing is not a person's message: the + * chip split, the end alignment and the bubble are all wrong for it, and each one of them would + * have to learn about firings separately if this branched any later. + */ + const firing = isUser ? readFiring(text) : null; + if (firing !== null) { + return ( + + + + + + + + ); + } const align = isUser ? "end" : "start"; const invoked = isUser ? splitSkillChip(text, commandNames) : null; @@ -618,6 +662,24 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) { ); } +/** + * Whether a text item is the person actually sending something, as opposed to a routine firing that + * merely arrived wearing `role: "user"`. + * + * A FIRING IS NOT THE PERSON SPEAKING — `TranscriptMessage` already knows that and draws it as + * `RoutineFiring` rather than as their bubble, via the same `readFiring` check used here. The scroll + * machinery below was the one place left that had not caught up: it smooth-scrolled and anchored on + * `role === "user"` alone, so a routine firing while somebody was reading back through the channel + * yanked their viewport to the bottom as though they had just typed and sent something. They hadn't; + * the schedule had. Exported so this can be checked without mounting anything. + */ +export function isPersonSentMessage( + role: "user" | "assistant", + text: string, +): boolean { + return role === "user" && readFiring(text) === null; +} + const SEND_SCROLL_MS = 700; function useSmoothSendScroll( @@ -689,8 +751,10 @@ export function ChatTranscript({ const viewportRef = useRef(null); const newestUserMessageId = - items.findLast((item) => item.kind === "text" && item.role === "user") - ?.id ?? null; + items.findLast( + (item) => + item.kind === "text" && isPersonSentMessage(item.role, item.text), + )?.id ?? null; useSmoothSendScroll(viewportRef, newestUserMessageId); /* @@ -767,7 +831,7 @@ export function ChatTranscript({ {bar ? (
- {hasSidebar ? : null} + {showToggle ? : null} {!!backButton && ( + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index 5f2b035aa..9c93af3a3 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -48,6 +48,16 @@ export type AgentProfile = { mine: boolean; }; +/** + * Whether this is an agent shared with you: made public by somebody else, not your own. + * + * Written once so the roster (`/`) and the browse screen (`/agents`) can't drift apart on what + * "shared with you" means — both filter their list through this, not a copy of the rule. + */ +export function isSharedWithYou(agent: AgentProfile): boolean { + return !agent.mine && agent.visibility === "public"; +} + export const agentKeys = { all: ["agents"] as const, list: (hidden = false) => ["agents", "list", { hidden }] as const, diff --git a/app/src/lib/channels/routine-firing.ts b/app/src/lib/channels/routine-firing.ts new file mode 100644 index 000000000..9d98e840b --- /dev/null +++ b/app/src/lib/channels/routine-firing.ts @@ -0,0 +1,8 @@ +/** + * The routine firing frame's reader, re-exported from the one place it is declared. + * + * `shared/` is where the server builds the frame from too, so a rewording changes both sides at + * once. This file exists so the browser code keeps importing through `@/`, and so the path to + * `shared/` is written down once rather than in every renderer. + */ +export { readFiring } from "../../../../shared/routine-firing"; diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index 7b82e403d..feb547749 100644 --- a/app/src/lib/plugins/tool-name.ts +++ b/app/src/lib/plugins/tool-name.ts @@ -25,14 +25,49 @@ export function readToolName(name: string): ToolName { const label = humanise(tool); /* - * The server is dropped when the action already says it. Vendors name a tool after the thing it - * searches, so `mcp__notes__search_notes` would otherwise read "Search notes notes", which looks - * like a bug rather than a label. + * The server is dropped when the action already names it as the thing acted upon. Vendors name a + * tool after the thing it acts on, so `mcp__notes__search_notes` would otherwise read "Search + * notes notes" and `mcp__routines__create_routine` "Create routine routines", both of which look + * like a bug rather than a label. A server key can itself be more than one word — `google-drive`, + * `google_drive` — so it is split into words the same way `humanise` splits the tool name, each + * singularised, and looked for as a contiguous run inside the label's words. Whole words in an + * unbroken sequence, never a substring test: that is what let "Create routine routines" through in + * the first place. + * + * `humanise` always puts the verb first, and that leading word is excluded from the search: + * `mcp__posts__post_message` singularises its server to "post", which is also the tool's own verb, + * so without this exclusion "Post message" would lose its "posts" attribution over a coincidence + * with the verb rather than a naming of the server. Same shape for `mcp__lists__list_files` + * against "List". Only the words after the verb describe what the action was taken on, so only + * those are eligible to match the server. + * + * This does not, and cannot, catch every collision: `mcp__news__get_new_items` singularises "news" + * to "new", which genuinely is the second word of "Get new items", so the server is still dropped + * there. English plural heuristics cannot tell that "new" apart from the "new" in "news" — that is + * a known limit of this rule, not a bug to chase with a word list. */ - const named = label.toLowerCase().includes((server ?? "").toLowerCase()); + const labelWords = label.toLowerCase().split(" ").map(singular); + const wordsActedOn = labelWords.slice(1); + const serverWords = wordsOf(server ?? "").map(singular); + const named = containsPhrase(wordsActedOn, serverWords); return named ? { label } : { label, detail: server }; } +/** + * `routines` and `routine` are the same word for this purpose. + * + * A vendor names the server for the collection and the tool for the one item — + * `mcp__routines__create_routine` — so the exact-substring test that stops "Search notes notes" lets + * "Create routine routines" straight through, and it reads as a typo rather than as a label. + * + * Dropping one trailing `s` from each side before comparing is the whole of the difference between + * those two cases. This is not a stemmer and must not grow into one: the only thing it has to catch + * is one vendor writing the same noun twice, once plural and once not. + */ +function singular(word: string): string { + return word.endsWith("s") ? word.slice(0, -1) : word; +} + /** * `search_notes` as "Search notes". * @@ -41,11 +76,38 @@ export function readToolName(name: string): ToolName { * that reads as an action without anybody maintaining a table of names. */ function humanise(tool: string): string { - const words = tool + const words = wordsOf(tool).join(" "); + if (words.length === 0) return tool; + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** + * `google-drive`, `google_drive` and `googleDrive` all split to the same `["google", "drive"]`. + * + * The same splitting `humanise` does for a tool name, pulled out so a server key can be broken into + * words too rather than compared as one opaque token. + */ +function wordsOf(text: string): string[] { + return text .replace(/[_-]+/g, " ") .replace(/([a-z\d])([A-Z])/g, "$1 $2") .trim() - .toLowerCase(); - if (words.length === 0) return tool; - return words.charAt(0).toUpperCase() + words.slice(1); + .toLowerCase() + .split(" ") + .filter((word) => word.length > 0); +} + +/** + * Whether `needle` occurs in `haystack` as a run of whole words, in order and unbroken. + * + * This is the whole-word alternative to a substring test: `["routine"]` must line up with a word + * in `["create", "routine"]`, not merely appear inside one of its letters. + */ +function containsPhrase(haystack: string[], needle: string[]): boolean { + if (needle.length === 0) return false; + for (let start = 0; start + needle.length <= haystack.length; start++) { + if (needle.every((word, offset) => haystack[start + offset] === word)) + return true; + } + return false; } diff --git a/app/src/routes/_authed/_app/agents/index.tsx b/app/src/routes/_authed/_app/agents/index.tsx index dcea06e0d..1a96139cd 100644 --- a/app/src/routes/_authed/_app/agents/index.tsx +++ b/app/src/routes/_authed/_app/agents/index.tsx @@ -9,7 +9,8 @@ import { SidebarToggleBar } from "@/components/layout/sidebar-toggle"; import { StaggerItem } from "@/components/layout/stagger"; import { Button } from "@/components/ui/button"; import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; -import { agentListQueryOptions } from "@/lib/agents/queries"; +import { Skeleton } from "@/components/ui/skeleton"; +import { agentListQueryOptions, isSharedWithYou } from "@/lib/agents/queries"; /** * Creating and inspecting a coworker are search-parameter states so the roster remains mounted and @@ -36,15 +37,36 @@ export const Route = createFileRoute("/_authed/_app/agents/")({ * * The tracks are the card's own width, not `minmax(144px,1fr)`. A `1fr` track stretches to share * the container while the card inside it stays 144px, and the difference reads as a gap: at prose - * width that was three 190px columns holding 144px cards, so the 15px gutter looked like 61px. The - * home screen's Explore row is the reference — fixed cards, `gap-4`, nothing stretching. + * width that was three 190px columns holding 144px cards, so the 15px gutter looked like 61px. + * + * Both grids are block children of their section, and they have to be. `auto-fill` needs a definite + * width to divide into tracks; a grid placed inside a `flex flex-row` is a flex item sized + * shrink-to-fit, so `auto-fill` has nothing to fill and resolves to a single column. That is what + * put "Your agents" in a one-card column while "Explore agents", whose grid was never wrapped, + * flowed correctly three across on the very same page. Do not reintroduce a flex wrapper here to + * position the roster. */ function AgentsScreen() { const { new: isCreating, agent: selectedAgentId } = Route.useSearch(); const navigate = Route.useNavigate(); - const { data: agents } = useQuery(agentListQueryOptions()); + /* + * The two empty states below must not fire while the list is still arriving. `skills.tsx` learned + * this first: an empty state standing there saying somebody has created nothing is a claim the + * screen has not yet earned, and on a slow connection it is the first thing they read. + * + * `isPending` rather than `agents === undefined`, and the difference is the whole point on a + * screen whose job is to say when there is nothing. `data` is also undefined when the query + * FAILED, so deriving the flag from it holds the screen in its loading branch forever on an + * error — two headings over nothing, which is the exact shape this task exists to remove. + * `isPending` goes false either way, so a failure falls through to the empty state. + */ + const { + data: agents, + isPending: loading, + isError: failed, + } = useQuery(agentListQueryOptions()); const mine = agents?.filter((a) => a.mine); - const explore = agents?.filter((a) => !a.mine && a.visibility === "public"); + const explore = agents?.filter(isSharedWithYou); // Creating wins if both are somehow set: it is the more recent intent. const showCreate = isCreating === true; @@ -69,36 +91,68 @@ function AgentsScreen() { New agent
-
- {!!mine?.length && ( -
- {mine.map((agent, index) => { - return ( - - - - - - ); - })} -
- )} - {!mine?.length && ( - - - - You don't have any agents created. - - - - )} -
+ {loading ? ( + // Reserves the same 180px the settled arms below occupy, so this section holds its + // own height and the page beneath it does not jump when the query settles. + + ) : mine?.length ? ( + // Wins over `failed`: TanStack Query keeps the last good `data` across a failed + // background refetch (see query-core's error action — it spreads `...state` and + // never clears `data`), so `isError` and a still-populated roster are an ordinary + // combination, not a contradiction. A stale roster beats an error card claiming + // there is nothing, which would be false here. +
+ {mine.map((agent, index) => { + return ( + + + + + + ); + })} +
+ ) : failed && agents === undefined ? ( + // `agents === undefined` narrows this to "the query has never once returned + // successfully" — not merely "the last request errored". `?.length` alone can't + // tell that apart from a slice that loaded and is genuinely empty: TanStack Query + // never clears `data` on a failed background refetch, so once the query has + // resolved even one response, `agents` stays defined and `mine`'s emptiness is a + // fact about that response, not a symptom of the failure. Rendering the destructive + // card there would say the opposite of what "Explore agents" beside it (or this + // section itself, on a different roster) proves by rendering real cards from the + // same query. + + + + Your agents couldn't be loaded. + + + + ) : ( + // Reached both when the query never failed and `mine` is genuinely empty, and when + // it failed but `agents` is defined — a loaded, empty slice either way. Same plain + // copy for both: an empty roster is a fact, not an error. + + + + You don't have any agents created. + + + + )}

Explore agents

-
- {!!explore?.length && - explore.map((agent, index) => { + {loading ? ( + // Reserves the same 180px the settled arms below occupy, so this section holds its + // own height and the page beneath it does not jump when the query settles. + + ) : explore?.length ? ( + // Wins over `failed` for the same reason the "Your agents" section above does: a + // failed background refetch does not clear TanStack Query's cached `data`. +
+ {explore.map((agent, index) => { return ( @@ -107,7 +161,36 @@ function AgentsScreen() { ); })} -
+
+ ) : failed && agents === undefined ? ( + // `agents === undefined` narrows this to "the query has never once returned + // successfully" — not merely "the last request errored". `?.length` alone can't + // tell that apart from a slice that loaded and is genuinely empty: TanStack Query + // never clears `data` on a failed background refetch, so once the query has + // resolved even one response, `agents` stays defined and `explore`'s emptiness is a + // fact about that response, not a symptom of the failure. Rendering the destructive + // card there would say the opposite of what "Your agents" beside it (or this + // section itself, on a different roster) proves by rendering real cards from the + // same query. + + + + Agents shared with you couldn't be loaded. + + + + ) : ( + // Reached both when the query never failed and `explore` is genuinely empty, and + // when it failed but `agents` is defined — a loaded, empty slice either way. Same + // plain copy for both: an empty roster is a fact, not an error. + + + + Nobody has shared an agent with you yet. + + + + )}
!a.mine && a.visibility === "public"); + const { + data: agents, + isPending: loading, + isError: failed, + } = useQuery(agentListQueryOptions()); + const explore = agents?.filter(isSharedWithYou); const { start, startChosen, pending } = useStartChannel(); const [error, setError] = useState(null); @@ -86,24 +99,111 @@ function RouteComponent() { > {error}

+ ) : failed && agents === undefined ? ( + // The composer above is `disabled={!fallback}`, and a failed query does not by + // itself mean `fallback` is undefined: TanStack Query keeps its last good `data` + // across a failed background refetch, and `!fallback` alone is also true of a query + // that loaded successfully and genuinely returned zero agents — a case where nobody + // failed to load anything. `agents === undefined` is the one condition that is only + // true when the query has never once returned successfully, so this alert can only + // ever claim a load failure while that is actually what happened. +

+ Your coworkers couldn't be loaded, so there's no one to send this + to yet. +

) : null} + {/* + * A carousel rather than the wrapping grid `/agents` uses, and the difference is on purpose. + * This is a one-row teaser under the composer: a grid that wrapped here would push the row + * down the page every time somebody shared another Bot. `/agents` is the browse surface and + * wraps. + * + * What it replaces was `flex flex-row` with no wrap over cards that have no `shrink-0`, so + * the fifth public Bot squeezed all five — the same failure `/agents` had just been fixed + * for, still sitting here. + */}
-

Explore agents

-
- {!!explore?.length && - explore.map((agent) => ( - - - - ))} -
+ {/* + * The heading is repeated in each arm rather than hoisted above this conditional: + * `CarouselPrevious`/`CarouselNext` read the carousel's own context, so they must stay + * inside ``, and the heading shares that row with them once populated. Each + * arm also reserves the same ~180px of body beneath it — a skeleton here, the + * carousel's 144×180 cards, or the empty/error state's own `h-[180px]` — so the section + * holds its own height across all four states and the composer sitting above it on + * this centred column does not move when the query settles or fails. + */} + {loading ? ( + <> +

Explore agents

+ + + ) : explore?.length ? ( + // Wins over `failed`: a failed background refetch does not clear TanStack Query's + // cached `data`, so a stale carousel here beats an error card claiming there is + // nothing to explore, which would be false while this list is still populated. + +
+

Explore agents

+ {/* + * `static` undoes the primitive's own absolute placement, which parks these either + * side of the row and off the edge of a prose-width column. They belong on the + * heading's baseline, where the section's other decisions are. + */} +
+ + +
+
+ {/* `-ml-4`/`pl-4` is the primitive's own gap convention; `basis-auto` keeps each + slide the card's own 144px instead of a full-width slide. */} + + {explore.map((agent) => ( + + + + + + ))} + +
+ ) : failed && agents === undefined ? ( + // `agents === undefined` narrows this to "the query has never once returned + // successfully" — not merely "the last request errored". `?.length` alone can't + // tell that apart from a slice that loaded and is genuinely empty: TanStack Query + // never clears `data` on a failed background refetch, so once the query has + // resolved even one response, `agents` stays defined and `explore`'s emptiness is a + // fact about that response, not a symptom of the failure. Rendering the destructive + // card there would say the opposite of what a populated composer beside it (still + // working off that same, successfully loaded `agents`) proves. + <> +

Explore agents

+ + + + Agents shared with you couldn't be loaded. + + + + + ) : ( + // Reached both when the query never failed and `explore` is genuinely empty, and + // when it failed but `agents` is defined — a loaded, empty slice either way. Same + // plain copy for both: an empty roster is a fact, not an error. + <> +

Explore agents

+ + + + Nobody has shared an agent with you yet. + + + + + )}
diff --git a/app/tests/agent-roster-error.test.tsx b/app/tests/agent-roster-error.test.tsx new file mode 100644 index 000000000..284d7f6ff --- /dev/null +++ b/app/tests/agent-roster-error.test.tsx @@ -0,0 +1,581 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { type AgentProfile, agentKeys } from "@/lib/agents/queries"; +import { Route as AgentsRoute } from "@/routes/_authed/_app/agents/index"; +import { Route as HomeRoute } from "@/routes/_authed/_app/index"; + +/** + * Both agent-facing screens read the same `agentListQueryOptions()` query, and both drew their + * empty state on a FAILED query, not just an empty one: `isPending` (what each screen branches on) + * goes false on failure exactly as it does on success, so a broken fetch fell through to "you have + * nothing" and told somebody who may own twenty coworkers that they own none. + * + * THE HARNESS IS THIS REPOSITORY'S. `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in + * `afterEach`, and queries off `render()`'s own return, matching `proposed-bot-card.test.tsx` for + * the reason recorded there: bun walks every file into one process, and a document another file + * tore down mid-run fails invisibly. + * + * Each test builds its own `QueryClient` with `retry: false` — the app's own client (see + * `query-client.ts`) retries once, which is correct for production and would just slow this test + * down for no assertion it needs. Both screens are exercised through their real, exported `Route`, + * not a stand-in; see `renderAgents` below for what that costs on `/agents`. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; + +beforeEach(() => { + // Every read in this app goes through `client()` in `lib/client.ts`, which throws once the + // response is not `ok`. A 500 with no body is the shape a broken server actually sends, and is + // exactly what `client()`'s fallback message path exists for. + global.fetch = (async () => + new Response(null, { status: 500 })) as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +/** A client the failing query settles on in one attempt, so the test does not wait on a retry. */ +function failingQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +/** + * A client that already holds a successful `agents` list under the exact key + * `agentListQueryOptions()` reads (`agentKeys.list(false)`, the default `hidden` param both + * screens call it with), built on `failingQueryClient()` so the one refetch it triggers on mount + * settles without a retry. + * + * Combined with the always-failing `global.fetch` this file's `beforeEach` installs, mounting a + * screen against this client reproduces a failed BACKGROUND refetch: TanStack Query's default + * `refetchOnMount` fires a fetch immediately because `staleTime` is unset (0), that fetch hits the + * mocked 500, and `isError` becomes true while `data` — per query-core's error action, which + * spreads `...state` and never touches `data` — stays exactly this seeded roster. No fake timers or + * queryFn stand-in are needed: seeding the cache and letting the real, already-mocked fetch fail is + * the whole scaffold. + */ +function staleQueryClient(agents: AgentProfile[]) { + const queryClient = failingQueryClient(); + queryClient.setQueryData(agentKeys.list(false), agents); + return queryClient; +} + +/** A minimal but complete `AgentProfile`, overridable per test. */ +function agent( + overrides: Partial & { id: string }, +): AgentProfile { + return { + name: "Agent", + title: "Title", + roleDescription: "Role", + avatarSeed: "seed", + visibility: "private", + endpoint: null, + builtIn: true, + hasAuth: false, + hasCallbackToken: false, + hidden: false, + systemOwned: false, + canManage: true, + mine: true, + ...overrides, + }; +} + +/** Waits for the seeded query to have actually failed its background refetch, rather than trusting + * that the seeded data alone (which would render identically before any fetch ran) proves it. */ +async function waitForFailedRefetch(queryClient: QueryClient) { + await waitFor(() => { + expect(queryClient.getQueryState(agentKeys.list(false))?.status).toBe( + "error", + ); + }); +} + +/** + * `findByText`'s own default wait is 1000ms, and `/` additionally mounts the heavy rich-text + * `Composer` on top of the roster query this file already exercises, so a render on `/` is + * measurably slower than its `/agents` twin. Under load that gap crosses 1000ms and the default + * times out around 1010ms — not a logic bug (data is never cleared or corrupted through the error + * transition; this was checked with a `Profiler`), just too little headroom for a busy machine. Do + * not remove this as a redundant-looking argument: every `findByText` in a test that renders `/` + * needs it, and `/agents`-only tests do not, because they never mount `Composer`. + */ +const HOME_FIND_TIMEOUT = { timeout: 5000 }; + +/** `/`'s component makes no `Route.useSearch()` / `Route.useNavigate()` call of its own, so mounting + * it directly as a memory router's root is enough — no ancestor chain to reconstruct. */ +function renderHome(queryClient: QueryClient) { + const rootRoute = createRootRoute({ component: HomeRoute.options.component }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +/** + * `/agents`'s component calls `Route.useSearch()` and `Route.useNavigate()`, which read `this.id` + * off the very `Route` singleton the source file exports — so, unlike `/`, a bare root standing in + * for it is not enough; those hooks fail to resolve a match unless that exact object is present in + * the router's tree with the id it expects. + * + * That id is not a free choice: `Route.useSearch()`/`useNavigate()` read it from `this.id`, which + * TanStack Router computes by walking `getParentRoute()` up to the root and joining each ancestor's + * own id, so it is fixed by the file's real position — `/_authed/_app/agents/` for a leaf declared + * `createFileRoute("/_authed/_app/agents/")` under pathless `_authed`/`_app` parents. Reproducing + * that with two throwaway pathless routes (`id`-only, no `path`, so they contribute nothing to the + * URL — exactly what `_authed`/`_app` do) is enough for the join to land on the same id; it does not + * require the real ancestors, whose `beforeLoad` checks a signed-in session and whose components + * mount the sidebar shell and the Copilot provider — none of which this test is about. + * + * The real `Route` singleton is `.update()`d in place to attach to this decoy chain, because + * `Route.useSearch()`/`useNavigate()` are bound to the one object the source module exports — there + * is no way to hand the component a stand-in. `beforeEach`/`afterEach` capture and restore its + * *entire own state* — not just `options`, and not by replaying it through `.update()` — around each + * render, so a real router built elsewhere in this same bun process (there is exactly one: + * `router.test.ts`, which never inspects this route) is never left pointed at the decoy. + * + * A restore that merely replayed the captured `options` through `.update()` would not work: the + * installed `update()` (`@tanstack/router-core@1.171.27`'s `dist/esm/route.js`) is + * `Object.assign(this.options, options); return this;` — a merge. `Object.assign` only overwrites + * or adds keys, it never deletes one, so merging a snapshot that predates this file's `id`/`path`/ + * `getParentRoute` back on top leaves those decoy keys sitting in `options` untouched. Worse, + * `update()` never touches the *derived* state `router.js`'s `buildRouteTree()` computes by calling + * `route.init({ originalIndex })` once per route each time a router is built (`_id`, `_path`, + * `_fullPath`, `_to`, `parentRoute`, `originalIndex`) — those stay pinned to whatever this file's own + * `createRouter()` last resolved them to, decoy parent included, since nothing re-runs `init()` on + * `.update()`. The only restore that actually undoes a render is a full replace: snapshot every own + * property up front (with `options` itself shallow-cloned, since `update()` mutates that very object + * in place rather than replacing it) and, afterward, delete whatever the render added and reassign + * the rest verbatim. + */ +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} + +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} + +/** + * Captured exactly once, at module scope, the instant this line of top-level code runs — which is + * before any `test()` body in this file has had a chance to run. That timing, not any claim about + * the route being untouched, is what makes this the right fixed point. + * + * It is deliberately NOT described as "pristine in the absolute". `AgentsRoute` gains its derived + * keys (`_id`, `_fullPath`, `parentRoute`, ...) when `route.init()` runs, which `createRouter()` + * triggers — and that is NOT confined to this file: `app/src/router.tsx` calls `createRouter()` at + * module top level over the generated `routeTree`, which contains this very route, so merely + * importing it initialises `AgentsRoute`. `app/tests/router.test.ts` imports it, and bun runs every + * file in one process, so depending on file order this constant may capture a route that the real + * router has already initialised. + * + * That is fine, and it is the point: what this has to restore is the state the route was in before + * THIS file interfered with it, whatever that state was. Either way the rest of the process gets + * back exactly what it had. A snapshot taken in `beforeEach` instead — as this used to do — would + * be reading the *live*, already-rendered-on `AgentsRoute` from test two onward, which is exactly + * how a broken `restoreRouteState` (one that merges but never deletes the stray keys a render adds) + * went undetected: each test's "before" picture already had last test's leak baked in as normal. + * + * `captureRouteState` is called again on this constant, rather than using it directly, everywhere + * below it is needed. `restoreRouteState`'s `Object.assign(route, snapshot)` step aliases + * `route.options` to `snapshot.options` — not a clone — and `Route.update()` mutates `this.options` + * in place. Handing the very same object out of every `beforeEach` would let the next render's + * `.update()` mutate this "pristine" constant through that alias, corrupting the one fixed point + * this whole scheme depends on. Re-running it through `captureRouteState` produces a fresh + * `options` clone each time, so the constant itself is never written to after this line. + */ +const pristineAgentsRouteState = captureRouteState(AgentsRoute); + +let agentsRouteSnapshot: Record; + +beforeEach(() => { + agentsRouteSnapshot = captureRouteState(pristineAgentsRouteState); +}); + +afterEach(() => { + restoreRouteState(AgentsRoute, agentsRouteSnapshot); +}); + +function renderAgents(queryClient: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const appRoute = createRoute({ + id: "/_app", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const wired = ( + AgentsRoute as unknown as { + update: (options: unknown) => typeof AgentsRoute; + } + ).update({ + id: "/agents/", + path: "/agents/", + getParentRoute: () => appRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([appRoute.addChildren([wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ["/agents"] }), + }); + return render( + + + , + ); +} + +test("a failed roster on /agents reports the failure, not an empty roster", async () => { + const view = renderAgents(failingQueryClient()); + + expect(await view.findByText("Your agents couldn't be loaded.")).toBeTruthy(); + expect( + await view.findByText("Agents shared with you couldn't be loaded."), + ).toBeTruthy(); + + // The whole point: a person with agents must never be told they have none because the request + // that would have proven otherwise never came back. + expect(view.queryByText("You don't have any agents created.")).toBeNull(); + expect( + view.queryByText("Nobody has shared an agent with you yet."), + ).toBeNull(); +}); + +test("a failed roster on / reports the failure and explains the disabled composer", async () => { + const view = renderHome(failingQueryClient()); + + expect( + await view.findByText( + "Agents shared with you couldn't be loaded.", + {}, + HOME_FIND_TIMEOUT, + ), + ).toBeTruthy(); + expect( + view.queryByText("Nobody has shared an agent with you yet."), + ).toBeNull(); + + // The composer goes `disabled={!fallback}` on the very same failure, with nothing on screen + // saying why unless this alert renders. + expect( + await view.findByText( + "Your coworkers couldn't be loaded, so there's no one to send this to yet.", + {}, + HOME_FIND_TIMEOUT, + ), + ).toBeTruthy(); +}); + +test("both /agents sections hold a skeleton while the roster is pending, not an empty state", async () => { + // A fetch that never settles is `isPending` forever — the state each section's loading arm + // renders once the router has finished its own (also async) initial match, which is why this + // still waits rather than reading `view.container` on the very next line. + global.fetch = (() => new Promise(() => {})) as typeof fetch; + + const view = renderAgents(failingQueryClient()); + + const skeletons = await waitFor(() => { + const found = view.container.querySelectorAll('[data-slot="skeleton"]'); + expect(found.length).toBe(2); + return found; + }); + expect(skeletons.length).toBe(2); + + // A skeleton sitting beside a premature empty or error sentence would be no fix at all: the + // point is that loading reserves the section's height instead of claiming an answer it doesn't + // have yet. + expect(view.queryByText("You don't have any agents created.")).toBeNull(); + expect( + view.queryByText("Nobody has shared an agent with you yet."), + ).toBeNull(); + expect(view.queryByText("Your agents couldn't be loaded.")).toBeNull(); + expect( + view.queryByText("Agents shared with you couldn't be loaded."), + ).toBeNull(); +}); + +/* + * The three tests above all exercise a query that has NEVER succeeded: `isPending` and `isError` + * both come from a first attempt. TanStack Query keeps a query's last good `data` across a failed + * BACKGROUND refetch — the library's own comment on that code path reads "flag existing data as + * invalidated if we get a background error" — so `isError === true` beside a perfectly good, + * previously-loaded roster is an ordinary state, not the one the tests above cover. Both screens + * used to let `failed` outrank the populated-list check regardless of which of these two `isError` + * causes produced it, which replaced a working roster with an error card, and on `/` also showed an + * alert claiming the composer had nothing to send to while it was, in fact, still enabled. + */ +test("a failed REFETCH on /agents keeps the roster it already had, not the error", async () => { + const mine = agent({ id: "mine-1", name: "Mine Agent", mine: true }); + const shared = agent({ + id: "shared-1", + name: "Shared Agent", + mine: false, + visibility: "public", + }); + const queryClient = staleQueryClient([mine, shared]); + + const view = renderAgents(queryClient); + await waitForFailedRefetch(queryClient); + + expect(await view.findByText("Mine Agent")).toBeTruthy(); + expect(await view.findByText("Shared Agent")).toBeTruthy(); + expect(view.queryByText("Your agents couldn't be loaded.")).toBeNull(); + expect( + view.queryByText("Agents shared with you couldn't be loaded."), + ).toBeNull(); +}); + +test("a failed REFETCH on / keeps the roster and does not disclaim the composer", async () => { + const shared = agent({ + id: "shared-1", + name: "Shared Agent", + mine: false, + visibility: "public", + }); + const queryClient = staleQueryClient([shared]); + + const view = renderHome(queryClient); + await waitForFailedRefetch(queryClient); + + expect( + await view.findByText("Shared Agent", {}, HOME_FIND_TIMEOUT), + ).toBeTruthy(); + // Only renders while `fallback` is set, which the retained roster still supplies — the direct + // evidence that the composer is not the disabled, nothing-to-send-to state its alert describes. + expect( + await view.findByText( + "Sent to the coworker it is for.", + { exact: false }, + HOME_FIND_TIMEOUT, + ), + ).toBeTruthy(); + expect( + view.queryByText( + "Your coworkers couldn't be loaded, so there's no one to send this to yet.", + ), + ).toBeNull(); + expect( + view.queryByText("Agents shared with you couldn't be loaded."), + ).toBeNull(); +}); + +/* + * A failed REFETCH can also land on cache that is ASYMMETRIC: one slice populated, its sibling + * genuinely empty. `?.length` cannot tell "loaded, and this slice is empty" apart from "never + * loaded" — both read as falsy — so gating the destructive arm on `failed` alone (once the + * populated-list check above it doesn't fire) puts the "couldn't be loaded" card on the empty + * sibling, right beside a section rendering real cards from that very same query. The real cards + * are the proof: the response came back, and this slice of it is just empty. + */ +test("a failed REFETCH on /agents with one empty slice shows it as empty, not broken", async () => { + const mine = agent({ id: "mine-1", name: "Mine Agent", mine: true }); + const queryClient = staleQueryClient([mine]); + + const view = renderAgents(queryClient); + await waitForFailedRefetch(queryClient); + + expect(await view.findByText("Mine Agent")).toBeTruthy(); + expect( + await view.findByText("Nobody has shared an agent with you yet."), + ).toBeTruthy(); + expect( + view.queryByText("Agents shared with you couldn't be loaded."), + ).toBeNull(); +}); + +test("a failed REFETCH on /agents with the other slice empty also shows it as empty", async () => { + const shared = agent({ + id: "shared-1", + name: "Shared Agent", + mine: false, + visibility: "public", + }); + const queryClient = staleQueryClient([shared]); + + const view = renderAgents(queryClient); + await waitForFailedRefetch(queryClient); + + expect(await view.findByText("Shared Agent")).toBeTruthy(); + expect( + await view.findByText("You don't have any agents created."), + ).toBeTruthy(); + expect(view.queryByText("Your agents couldn't be loaded.")).toBeNull(); +}); + +test("a failed REFETCH on / with explore empty shows it as empty, not broken", async () => { + const mine = agent({ id: "mine-1", name: "Mine Agent", mine: true }); + const queryClient = staleQueryClient([mine]); + + const view = renderHome(queryClient); + await waitForFailedRefetch(queryClient); + + expect( + await view.findByText( + "Nobody has shared an agent with you yet.", + {}, + HOME_FIND_TIMEOUT, + ), + ).toBeTruthy(); + expect( + view.queryByText("Agents shared with you couldn't be loaded."), + ).toBeNull(); + // `agents` loaded (it holds "Mine Agent"), so `fallback` falls back to it and the composer is + // enabled — the alert claiming a load failure must not appear beside that working composer. + expect( + view.queryByText( + "Your coworkers couldn't be loaded, so there's no one to send this to yet.", + ), + ).toBeNull(); +}); + +/** + * Proves the restore itself, rather than trusting the doc comment above it: it drives + * `captureRouteState`/`restoreRouteState` directly, sandwiched around a corruption that reproduces + * both halves of what `renderAgents` does to the singleton — the `.update()` merge that plants + * `id`/`path`/`getParentRoute`, and the `route.init()` call `createRouter()` makes for every route in + * a tree it builds, which is what actually derives `_id`/`_fullPath`/`_to`/`parentRoute` from those + * options. Checking inside a test's own body can never observe what that test's own `afterEach` did + * — the hook has not run yet at that point — so this calls `restoreRouteState` itself rather than + * waiting on a hook, which observes the exact same restore path `afterEach` uses without depending on + * bun's cross-test hook ordering. + * + * `before` is `pristineAgentsRouteState`, not a fresh `captureRouteState(AgentsRoute)` read here. + * This test runs last, after every `renderAgents()`-driven test before it has already rendered on + * (and had its `afterEach` "restore") the live singleton; reading `AgentsRoute` at this point trusts + * that every prior restore actually worked, which is the very thing under test here. Comparing + * against the one snapshot taken before any router ever touched the route is what turns a leaked key + * into a visible diff instead of two contaminated pictures agreeing with each other. + */ +test("restoring after a decoy render leaves no trace on the exported Route singleton", () => { + const before = captureRouteState(pristineAgentsRouteState); + + const decoyParentRoute = { id: "/_app", fullPath: "/" }; + const route = AgentsRoute as unknown as { + update: (options: unknown) => unknown; + init: (opts: { originalIndex: number }) => void; + }; + route.update({ + id: "/agents/", + path: "/agents/", + getParentRoute: () => decoyParentRoute, + }); + route.init({ originalIndex: 0 }); + + // Sanity check: the corruption actually took, so the restore below proves something. + expect((AgentsRoute as { parentRoute: unknown }).parentRoute).toBe( + decoyParentRoute, + ); + + restoreRouteState(AgentsRoute, before); + + expect(captureRouteState(AgentsRoute)).toEqual(before); +}); + +/** + * The test above compares one `captureRouteState` snapshot against another. That is only as + * trustworthy as `captureRouteState`'s own `options` clone and `beforeEach`'s own re-clone off + * `pristineAgentsRouteState` — and this file has now shipped three separate breakages that drop + * one of those two clones. Each one aliases `pristineAgentsRouteState.options` to the live route's + * `options` object, so `Route.update()`'s in-place mutation reaches the "pristine" constant too. + * Once that happens, `before`'s own `options` in the test above is read off the very same corrupted + * constant that `AgentsRoute` gets restored to, so the two sides of that `toEqual` are corrupted in + * lockstep and agree with each other anyway — the assertion goes tautological and the suite stays + * green with the pristine constant silently ruined for the rest of the process. + * + * The only way to catch that is to anchor to something the corruption itself cannot drag along: an + * object-identity check against `pristineAgentsRouteState` itself, and a second identity check + * against a decoy value created fresh inside THIS test — neither is derived from + * `pristineAgentsRouteState` or `agentsRouteSnapshot` the way `before` is, so neither can be dragged + * into the corruption alongside them. + * + * The second anchor is deliberately NOT "these key names must be absent from `options`" (`id`, + * `path`, `getParentRoute`), even though those are the very keys this test's own decoy `.update()` + * plants: `app/src/routeTree.gen.ts` calls the REAL `AgentsRoute.update({ id: '/agents/', path: + * '/agents/', getParentRoute: () => AuthedAppRoute })` too, as part of wiring the real app router — + * see `app/src/router.tsx`. Whenever this file shares a bun process with anything that imports that + * real router (e.g. `router.test.ts`, or any component test that mounts the real app shell), those + * exact key names are legitimately present in the pristine state this file must restore, so + * asserting their bare absence goes red on correct code the moment file ordering changes — the very + * "not pristine in the absolute" trap `pristineAgentsRouteState`'s own doc comment warns about. + * Anchoring to a function reference this test just created sidesteps that: no real code, past or + * future, can ever hold a reference to it. + * + * This test drives the exact same decoy-and-restore sequence as the one above, but reads + * `agentsRouteSnapshot` — the real module-scope variable this test's own `beforeEach` already + * populated, the same one the real `afterEach` restores through — rather than taking a fresh + * snapshot of its own, so it is exercising the actual hook wiring rather than a stand-in for it. + */ +test("the pristine snapshot's options is never the live route's, and a restore leaves no decoy behind — checked by identity, not against another snapshot", () => { + const decoyParentRoute = { id: "/_app", fullPath: "/" }; + const decoyGetParentRoute = () => decoyParentRoute; + const route = AgentsRoute as unknown as { + update: (options: unknown) => unknown; + init: (opts: { originalIndex: number }) => void; + }; + route.update({ + id: "/agents/", + path: "/agents/", + getParentRoute: decoyGetParentRoute, + }); + route.init({ originalIndex: 0 }); + + restoreRouteState(AgentsRoute, agentsRouteSnapshot); + + const liveOptions = ( + AgentsRoute as unknown as { + options: { getParentRoute?: unknown }; + } + ).options; + + // Anchor 1 — identity, not a snapshot: the constant this whole file's restore promise rests on + // must never be the very object `Route.update()` writes into, restore or no restore. + expect(pristineAgentsRouteState.options).not.toBe(liveOptions); + + // Anchor 2 — identity against a value created fresh in this test, not a snapshot and not a key + // name: no real caller, past or future, can ever hold a reference to this closure, so its + // survival past the restore is unambiguous corruption either way. + expect(liveOptions.getParentRoute).not.toBe(decoyGetParentRoute); +}); diff --git a/app/tests/agent-shared-with-you.test.ts b/app/tests/agent-shared-with-you.test.ts new file mode 100644 index 000000000..5a3db04b4 --- /dev/null +++ b/app/tests/agent-shared-with-you.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentProfile } from "@/lib/agents/queries"; +import { isSharedWithYou } from "@/lib/agents/queries"; + +/** + * "Shared with you" on the roster (`/`) and the browse screen (`/agents`) is one rule, not two + * copies of the same expression: a coworker somebody else made public. Your own coworkers do not + * count, however visible, and a private coworker of somebody else's does not count either. + */ + +function agent(overrides: Partial): AgentProfile { + return { + id: "agent_1", + name: "Renewal Desk", + title: "Accounts Receivable", + roleDescription: "Chase overdue invoices.", + avatarSeed: "renewal-desk", + visibility: "public", + endpoint: null, + builtIn: false, + hasAuth: false, + hasCallbackToken: false, + hidden: false, + systemOwned: false, + canManage: false, + mine: false, + ...overrides, + }; +} + +describe("an agent shared with you", () => { + test("your own agent does not qualify, however visible", () => { + expect(isSharedWithYou(agent({ mine: true, visibility: "public" }))).toBe( + false, + ); + }); + + test("a public agent belonging to someone else qualifies", () => { + expect(isSharedWithYou(agent({ mine: false, visibility: "public" }))).toBe( + true, + ); + }); + + test("a private agent belonging to someone else does not qualify", () => { + expect(isSharedWithYou(agent({ mine: false, visibility: "private" }))).toBe( + false, + ); + }); +}); diff --git a/app/tests/person-sent-message.test.ts b/app/tests/person-sent-message.test.ts new file mode 100644 index 000000000..366951367 --- /dev/null +++ b/app/tests/person-sent-message.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { frameFiring } from "../../shared/routine-firing"; +import { isPersonSentMessage } from "../src/components/channels/chat-transcript"; + +/** + * A routine firing is persisted with `role: "user"`, which is right for the model — it is the turn's + * user message — and wrong for anything that reads the role as "the person did this". The transcript + * itself already knows the difference (`RoutineFiring` vs. a person's bubble); this is the predicate + * the scroll machinery uses to catch up, so a firing landing while somebody is reading back through + * the channel does not yank their viewport as though they had just sent something. + */ + +describe("isPersonSentMessage", () => { + test("a person's own plain message is theirs", () => { + expect(isPersonSentMessage("user", "when does the offer expire?")).toBe( + true, + ); + }); + + test("a routine firing wears role: user but was never typed by anyone", () => { + const firing = frameFiring("Check the queue and summarize backlog age."); + expect(isPersonSentMessage("user", firing)).toBe(false); + }); + + test("an assistant message is never the person's, framed or not", () => { + expect(isPersonSentMessage("assistant", "Here is what changed.")).toBe( + false, + ); + }); +}); diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts index 21693d04a..c2a5b40a1 100644 --- a/app/tests/tool-name.test.ts +++ b/app/tests/tool-name.test.ts @@ -22,6 +22,15 @@ describe("naming a tool call", () => { }); }); + test("the server is dropped when the action names it in the singular", () => { + // A vendor names the server for the collection and the tool for the one item it acts on, so the + // label is singular where the server is plural. The reader should not be shown "Create routine + // routines". + expect(readToolName("mcp__routines__create_routine")).toEqual({ + label: "Create routine", + }); + }); + test("camelCase from a vendor reads the same way", () => { // The server is dropped here too: the vendor put it in the tool name themselves. expect(readToolName("mcp__jira__searchJiraIssues")).toEqual({ @@ -42,4 +51,49 @@ describe("naming a tool call", () => { // These names were chosen by somebody and are already what the reader should see. expect(readToolName("showBarChart")).toEqual({ label: "showBarChart" }); }); + + test("the server is dropped when a hyphenated server's words are already in the label", () => { + // The server is one token with a separator inside it, `google-drive`. A word-at-a-time compare + // against the label's words never matches a token that never appears as a whole word itself, so + // this needs the same splitting `humanise` already does for the tool name. + expect(readToolName("mcp__google-drive__search_google_drive")).toEqual({ + label: "Search google drive", + }); + }); + + test("the server is dropped when an underscored server's words are already in the label", () => { + // Same case as the hyphenated server, spelled with an underscore instead. Either separator has to + // split into the same words. + expect(readToolName("mcp__google_drive__search_google_drive")).toEqual({ + label: "Search google drive", + }); + }); + + test("a multi-token server the label does not name is kept as detail", () => { + // "Search files" does not say "google" or "drive" anywhere, so the server still belongs on + // screen. This is what proves the check was tightened rather than deleted. + expect(readToolName("mcp__google-drive__search_files")).toEqual({ + label: "Search files", + detail: "google-drive", + }); + }); + + test("the server is kept when the singular of its name only matches the label's verb", () => { + // singular("posts") is "post", which is also the verb `humanise` put first in "Post message". + // That is a coincidence with the verb, not the server being named as the thing acted upon, so + // "posts" still belongs on screen. + expect(readToolName("mcp__posts__post_message")).toEqual({ + label: "Post message", + detail: "posts", + }); + }); + + test("the server is kept when the singular of its name only matches the label's verb, second case", () => { + // Same shape as `posts`/`post_message`: singular("lists") is "list", which collides with the + // verb in "List files" rather than naming the server as the thing acted upon. + expect(readToolName("mcp__lists__list_files")).toEqual({ + label: "List files", + detail: "lists", + }); + }); }); diff --git a/bun.lock b/bun.lock index a117e3901..b116c2a30 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "motion": "^13.1.0", "prompt-area": "^0.6.3", "react": "^19.2.0", @@ -1179,6 +1180,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.413", "", {}, "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts index 81fc92152..9902fbdb8 100644 --- a/server/src/channels/summary.ts +++ b/server/src/channels/summary.ts @@ -8,6 +8,7 @@ * event, so a missed sweep costs two seconds where a missed event would cost the name entirely. */ import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { readFiring } from "../../../shared/routine-firing"; import type { Database } from "../db/client"; import { channelMemberships, @@ -277,8 +278,12 @@ async function openingOf( (message) => message.role === "assistant", ); - const asked = textOf(question.content); - if (!asked) return null; + const rawAsked = textOf(question.content); + if (!rawAsked) return null; + // A channel a routine opened has the firing frame wrapped around its first message, not a + // person's words. Unwrapped before it can become the title, same as the transcript unwraps it + // before it can become what a person reads. + const asked = readFiring(rawAsked) ?? rawAsked; const replied = answer ? textOf(answer.content) : ""; return oneLine( diff --git a/server/src/routines/run-turn.ts b/server/src/routines/run-turn.ts index aad4793ce..03c51c550 100644 --- a/server/src/routines/run-turn.ts +++ b/server/src/routines/run-turn.ts @@ -55,6 +55,7 @@ import type { RunAgentInput, } from "@ag-ui/client"; import { EventType } from "@ag-ui/client"; +import { frameFiring } from "../../../shared/routine-firing"; import { sanitizeSeededHistory } from "../agents/history-sanitize"; import type { AuditInitiator } from "../audit"; import { historyOrEmpty } from "../copilot"; @@ -214,45 +215,19 @@ function assistantText(message: Message): string | undefined { : undefined; } -/** - * The stored instruction, wrapped in the sentences that tell the turn it IS a firing. - * - * FOUND ON A LIVE FIRING, and it recorded `succeeded`. The instruction read "Every run, append the - * current date and time as a new bulleted list item to the Notion page …" and was sent to the model - * verbatim as the turn's user message. The model read it as a question about routine MANAGEMENT - * rather than as work: it called `list_routines`, found a routine that already said exactly that, - * answered that it was already configured, and appended nothing. Nothing failed, so nothing was - * reported — a routine telling somebody it is working while doing nothing at all, which is worse than - * one that breaks. - * - * And the model was not being stupid. Instructions are WRITTEN in schedule-speak — "every run", - * "every 15 minutes", "each morning" — because that is how a person asks for a standing thing, and - * schedule-shaped prose arriving out of nowhere reads as a request to SET UP a schedule. The most - * plausible reading of its own routine's text was "check whether this is set up"; it was, so it did - * nothing, successfully. No wording of the stored instruction fixes that on its own, because the - * sentence a person writes is the sentence that describes the schedule. +/* + * The frame is declared in `shared/` because the transcript has to recognise it again — see + * `readFiring` there. Imported AND re-exported, and it needs both: the turn runner below calls it to + * build this turn's message, and `server/tests/routine-run-turn.test.ts:7` imports it from this + * module, which is the right place for it to look because this is the module that decides a firing + * is framed at all. A bare `export … from` would satisfy the test and leave the call site + * unresolved. * - * So the frame says the three things the instruction cannot say about itself: that this is a - * scheduled firing happening now, that the work belongs in this turn, and that managing routines is - * not what was asked. It is PRESENTATION — which is why it lives here and not in the stored row or in - * {@link TurnRunner}'s signature: the row keeps what the person asked for, and this is how it is put - * to the model. - * - * ONLY THE NEW MESSAGE IS FRAMED, and that matters twice. The framed text is what - * `persistedInputMessages` writes to the transcript — correctly, since the transcript should show - * what the turn was actually asked — so it comes back as HISTORY on the next firing. History is - * converted and seeded exactly as the platform handed it over and nothing re-frames it; a test holds - * that, because the alternative is a message that grows a fresh paragraph of frame every night. + * No line number for the call site on purpose. An intra-file reference shifts every time anything + * above it grows or shrinks — deleting the doc comment this replaced moved that call by thirty + * lines — so it would be stale on arrival. The cross-file citation above does not have that problem. */ -export function frameFiring(instruction: string): string { - return [ - "One of your routines is firing right now, on its schedule, and this is that firing.", - "Carry out the instruction below in this turn: do the work now, then say what happened.", - "Do not create, list or change any routine unless the instruction itself asks you to.", - "", - instruction, - ].join("\n"); -} +export { frameFiring }; export function createTurnRunner(options: { intelligence: IntelligenceLike; diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts index b71e15430..54795368d 100644 --- a/server/tests/channel-summary.integration.test.ts +++ b/server/tests/channel-summary.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; +import { FIRING_FRAME, frameFiring } from "../../shared/routine-firing"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; import { createChannelStore } from "../src/channels/routes"; @@ -265,6 +266,29 @@ describe("naming a claimed conversation", () => { expect(row?.summaryAt).toBeInstanceOf(Date); }); + test("titles a routine-opened channel from the instruction, not the firing frame", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + let excerptSeen = ""; + await summariseClaimedChannels( + options({ + // A channel a routine opened has its first message framed, not typed by a person: see + // `shared/routine-firing.ts`. The title has to come from the instruction inside the frame. + transcript: transcriptOf(frameFiring("Post the standup summary.")), + title: async (excerpt) => { + excerptSeen = excerpt; + return "Standup summary"; + }, + }), + ); + + expect(excerptSeen).toContain("Post the standup summary."); + expect(excerptSeen).not.toContain(FIRING_FRAME[0]); + expect((await summaryOf(channel.id))?.summary).toBe("Standup summary"); + }); + test("two replicas racing for the same conversation name it once", async () => { const owner = await createUser(); const channel = await createUsedChannel(owner); diff --git a/shared/routine-firing.test.ts b/shared/routine-firing.test.ts new file mode 100644 index 000000000..4ac882d13 --- /dev/null +++ b/shared/routine-firing.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { frameFiring, readFiring } from "./routine-firing"; + +const INSTRUCTION = + "Visit https://hackernews.com, identify the top-ranked result on the page, and post its title and link."; + +describe("frameFiring", () => { + test("puts the three frame sentences above the instruction", () => { + expect(frameFiring(INSTRUCTION)).toBe( + [ + "One of your routines is firing right now, on its schedule, and this is that firing.", + "Carry out the instruction below in this turn: do the work now, then say what happened.", + "Do not create, list or change any routine unless the instruction itself asks you to.", + "", + INSTRUCTION, + ].join("\n"), + ); + }); +}); + +describe("readFiring", () => { + test("gives back exactly the instruction that was framed", () => { + expect(readFiring(frameFiring(INSTRUCTION))).toBe(INSTRUCTION); + }); + + test("keeps an instruction that runs over several lines whole", () => { + const multi = "Check the board.\n\nThen post what changed."; + expect(readFiring(frameFiring(multi))).toBe(multi); + }); + + test("says no to a message a person wrote themselves", () => { + expect(readFiring("Check hackernews every 30 minutes")).toBeNull(); + }); + + test("says no to a message that only quotes one frame sentence", () => { + expect( + readFiring( + "One of your routines is firing right now, on its schedule, and this is that firing.", + ), + ).toBeNull(); + }); + + test("is a firing even when the wrapped instruction is blank", () => { + expect(readFiring(frameFiring(""))).toBe(""); + }); + + test("is a firing even when the wrapped instruction is only whitespace", () => { + expect(readFiring(frameFiring(" "))).toBe(" "); + }); + + test("round-trips an instruction with leading and trailing whitespace exactly, untrimmed", () => { + const padded = " do the thing "; + expect(readFiring(frameFiring(padded))).toBe(padded); + }); +}); diff --git a/shared/routine-firing.ts b/shared/routine-firing.ts new file mode 100644 index 000000000..86081a1b6 --- /dev/null +++ b/shared/routine-firing.ts @@ -0,0 +1,79 @@ +/** + * The sentences that tell a scheduled turn it IS a firing, declared once and read from both sides. + * + * ONE DECLARATION, READ FROM BOTH SIDES, for the same reason `handoff-markers.ts` gives: the server + * writes this text into the transcript and the browser has to recognise it again to draw it as a + * firing rather than as something the person typed. Two copies of a sentence is a contract with two + * authors, and the first rewording breaks the renderer silently. + */ +export const FIRING_FRAME = [ + "One of your routines is firing right now, on its schedule, and this is that firing.", + "Carry out the instruction below in this turn: do the work now, then say what happened.", + "Do not create, list or change any routine unless the instruction itself asks you to.", +] as const; + +/** The frame and the blank line under it, which is what `readFiring` strips. */ +const PREFIX = [...FIRING_FRAME, "", ""].join("\n"); + +/** + * The stored instruction, wrapped in the sentences that tell the turn it IS a firing. + * + * FOUND ON A LIVE FIRING, and it recorded `succeeded`. The instruction read "Every run, append the + * current date and time as a new bulleted list item to the Notion page …" and was sent to the model + * verbatim as the turn's user message. The model read it as a question about routine MANAGEMENT + * rather than as work: it called `list_routines`, found a routine that already said exactly that, + * answered that it was already configured, and appended nothing. Nothing failed, so nothing was + * reported — a routine telling somebody it is working while doing nothing at all, which is worse + * than one that breaks. + * + * And the model was not being stupid. Instructions are WRITTEN in schedule-speak — "every run", + * "every 15 minutes", "each morning" — because that is how a person asks for a standing thing, and + * schedule-shaped prose arriving out of nowhere reads as a request to SET UP a schedule. The most + * plausible reading of its own routine's text was "check whether this is set up"; it was, so it did + * nothing, successfully. No wording of the stored instruction fixes that on its own, because the + * sentence a person writes is the sentence that describes the schedule. + * + * So the frame says the three things the instruction cannot say about itself: that this is a + * scheduled firing happening now, that the work belongs in this turn, and that managing routines is + * not what was asked. It is PRESENTATION — which is why it lives here and not in the stored row or + * in the routine runner's signature: the row keeps what the person asked for, and this is how it is + * put to the model. + * + * ONLY THE NEW MESSAGE IS FRAMED, and that matters twice. The framed text is what + * `persistedInputMessages` writes to the transcript — correctly, since the transcript should show + * what the turn was actually asked — so it comes back as HISTORY on the next firing. History is + * converted and seeded exactly as the platform handed it over and nothing re-frames it; a test holds + * that, because the alternative is a message that grows a fresh paragraph of frame every night. + * + * Because the frame reaches the transcript, {@link readFiring} exists to take it off again for the + * readers it was never addressed to: the transcript a person reads, and the titler that names the + * channel from what was asked. + */ +export function frameFiring(instruction: string): string { + return [...FIRING_FRAME, "", instruction].join("\n"); +} + +/** + * The instruction back out of a framed message, or null if this was not one. + * + * The return value answers exactly one question for its callers: IS THIS TEXT A ROUTINE FIRING? + * `null` means no, and every caller acts on that — the transcript falls through to drawing the text + * as a message the person wrote, and the titler falls back to the raw text for the channel's name. + * So `null` must mean "not a firing" and NOTHING ELSE. A frame wrapping a blank instruction IS a + * firing — the schedule ran, the frame is intact — so it returns the (empty) instruction, never + * null. Deciding here that a blank instruction is not "worth showing as one" lies to every caller + * about whether a firing happened; that is a presentation choice for whoever draws the text, not + * something this function gets to make by returning the same null it uses for "not a firing". + * + * A prefix match on the whole frame rather than on its first sentence: the frame is three fixed + * lines and a blank one, and a person quoting one of them into a channel — which is exactly what + * somebody debugging a routine does — must not have their own message redrawn as a firing. + * + * The instruction is returned UNTRIMMED, exactly as `frameFiring` was given it, so a caller that + * compares or round-trips the text never disagrees with another caller over leading or trailing + * whitespace. + */ +export function readFiring(text: string): string | null { + if (!text.startsWith(PREFIX)) return null; + return text.slice(PREFIX.length); +}