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 ? (
- {!!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`.
+
+ ) : 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.
+ */}
+ {/*
+ * 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);
+}