feat(webapp,sdk): dashboard agent plan enforcement, component gallery — and fixes (#4516)

Plan enforcement for the dashboard agent — message quota and watch
limits — plus the component gallery, fixes and test hardening from the
same stack (#4548, #4549, #4550, #4552, #4556 merged here).

## Plan enforcement
([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863))

**Agent message quota.** The Free-plan allowance becomes a real
server-side limit with a durable counter. New `agent_message_usage`
table keyed `(organization_id, period)` — deliberately not joined to
chats, so deleting a chat can't free quota within the period. Both send
paths count one user message (wakes never count) and refuse at the cap
with `403 message_quota_reached`, which the client renders as an upgrade
panel, never a silent drop. The refusal code is a single shared constant
on both sides.

**Watch limits.** A watch whose window exceeds the plan's
`agentWatchMaxHours`, or that would push the org past its
`agentWatchers` count, is refused with `watch_limit_reached` (409 on the
API, an upgrade hint on the card). Plan limits only tighten the existing
code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A
plan limit of zero means zero, not unlimited. Questions answerable
instantly are answered before any plan refusal — a one-shot consumes no
slot and never sees an upgrade nag.

**Fails open by design.** Cloud ships the actual per-plan numbers
separately (TRI-12863 P0). Until then absent limits resolve to the
unlimited sentinel and the upgrade UI is gated on billing presence —
self-hosted sees no cap, no upsell, with tests proving the fallback.
Both quotas are nudges, not security boundaries: a failing limit read
never blocks a send.

## Component gallery

An admin-only gallery of every agent card state: five
`storybook.agent-*` pages (chat UI, view blocks, report, investigation,
watch) with their shared shell and manifest, demo fixtures, two
demo-only cards, toast examples, and the screenshot script. No LLM and
no data — every state renders from fixtures under
`dashboard-agent/demo/`, never reachable from a production path.
Designers and reviewers can look at every state, including the report
states, without seeding anything.

## And fixes

**SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065,
TRI-13070) — watch mode keeps reconnecting across empty long-poll
windows and only stops on abort or a settled session; a passive
subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is
explicit, default off). Review findings fixed alongside: a superseded
stream's async teardown no longer removes the live successor's abort
controller or multi-tab claim, and stopping a generation hands the chat
back to the user's other tabs.

**Query boundary pinned end-to-end**
([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a
route-level test drives `api.v1.query` with a real signed environment
JWT (writes refused before ClickHouse, a read passes); `readonly=1` made
non-overridable; a per-turn cap stops the model burning a turn rewriting
a query it can't fix (deterministic SQL errors only — busy/transport
rejections don't count).

**chat.agent durability regression suite**
([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) —
testcontainers-backed coverage of the two audit criticals (cross-tenant
isolation, no duplicate mid-stream turn, both control-broken) plus
crash-resume, cursor-based refresh, clean rollback of a mid-write turn
failure (torn by a real constraint violation), and OOM-restart replay.

**Investigation sweep backoff** — stale investigations get an attempt
counter and backoff so a poison row can't pin the sweep queue head
(migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`).

## Screenshots

<img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19"
src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Katia Bulatova
2026-08-12 13:59:35 +02:00
committed by GitHub
parent ed1bb72fb8
commit 480bede0ad
84 changed files with 9786 additions and 243 deletions
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Watch-mode chat streams now survive quiet windows and page reloads, and a reply cut off by a lost connection shows an error instead of appearing finished. Aborting a resumed subscription only closes your local stream — call `stopGeneration(chatId)` or pass `stopOnAbort: true` to stop the run. Also fixed a race where quickly restarting a stream could break stop and reconnect, and stopping a chat now hands it back to your other tabs instead of leaving them read-only.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
The dashboard agent now has a monthly message allowance and plan-based limits on watches. Queries stay read-only with clearer errors when busy, and messages with unusual characters no longer fail to send.
@@ -1,18 +1,21 @@
import { Link } from "@remix-run/react";
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
import { LinkButton } from "~/components/primitives/Buttons";
import { useOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { v3BillingPath } from "~/utils/pathBuilder";
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
import { ASK_AGENT_LABEL } from "./agent-identity";
import { messageQuotaReachedCopy } from "./message-quota";
// Matches the composer's outer geometry so the replacement lands in the same place.
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
export function AgentUpgradeBlock({
limit,
planResolved,
context,
}: {
limit: number;
planResolved: boolean;
context?: React.ReactNode;
}) {
const organization = useOrganization();
@@ -22,14 +25,12 @@ export function AgentUpgradeBlock({
{context}
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
<div className="flex items-center gap-1.5">
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
<AgentMonoLogo size={16} decorative className="shrink-0" />
<span className="text-sm font-medium text-text-bright">
Upgrade to unlock {ASK_AGENT_LABEL}
</span>
</div>
<p className="text-xs text-text-dimmed">
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
</p>
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit, planResolved)}</p>
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
Upgrade
</LinkButton>
@@ -17,6 +17,12 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentHero } from "./DashboardAgentHero";
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
import {
FREE_PLAN_MESSAGE_LIMIT,
MESSAGE_QUOTA_REACHED_REASON,
parseQuotaReachedResponse,
type MessageQuota,
} from "./message-quota";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { navigateDestination } from "./navigate-target";
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -73,6 +79,7 @@ export function DashboardAgentChat({
onCancelWatch,
onTurnSettled,
onActivityChange,
onQuotaChange,
}: {
chatId: string;
initialMessages: UIMessage[];
@@ -100,8 +107,15 @@ export function DashboardAgentChat({
onCancelWatch: (watchId: string) => void;
onTurnSettled: () => void;
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
/** The poll lives here, so this is where the panel learns the cap has lifted. */
onQuotaChange?: (quota: MessageQuota) => void;
}) {
const [input, setInput] = useState("");
// Set when the server refuses a send over the cap, so the block shows at once rather than
// waiting for the next quota poll.
const [quotaReached, setQuotaReached] = useState<{ limit: number; planResolved: boolean } | null>(
null
);
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
@@ -128,6 +142,18 @@ export function DashboardAgentChat({
.catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
}
// Over the message cap: show the upgrade block instead of a generic turn error.
if (res.status === 403) {
const data = (await res
.clone()
.json()
.catch(() => null)) as { error?: string; limit?: number } | null;
const reached = parseQuotaReachedResponse(res.status, data);
if (reached) {
setQuotaReached(reached);
throw new Error("You've reached your message limit.");
}
}
return res;
},
clientData,
@@ -185,9 +211,20 @@ export function DashboardAgentChat({
const orderRef = useRef(createTranscriptOrder(initialMessages));
const messages = orderTranscript(rawMessages, orderRef.current);
// Counted here, not in the panel, so it includes the turn just sent.
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
const atMessageCap = quota.kind === "reached";
// Read here, not in the panel, so it re-reads as each turn settles.
const quota = useAgentMessageQuota({ actionPath, chatId, status });
useEffect(() => {
onQuotaChange?.(quota);
// The quota object is rebuilt every render; only its kind is acted on.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quota.kind, onQuotaChange]);
// Either the poll saw the cap, or a send was just refused over it.
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
const messageCapLimit =
quotaReached?.limit ?? (quota.kind === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit);
// The poll only runs on the free plan, so its cap is the free-plan nudge; a refusal
// carries the plan limit the server resolved.
const messageCapPlanResolved = quotaReached?.planResolved ?? false;
const isStreaming = status === "streaming";
// From status, not the last part: the indicator must stay up through silent tool calls.
@@ -252,6 +289,8 @@ export function DashboardAgentChat({
}, [sendRequest, submit, canSend]);
const retry = useCallback(() => {
// Over the cap, a retry only earns another 403 — same guard as `submit`.
if (atMessageCap) return;
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
const action = retryAction(
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
@@ -264,7 +303,7 @@ export function DashboardAgentChat({
return;
}
void sendMessage({ text: action.text, messageId: action.messageId });
}, [messages, sendMessage, regenerate, clearError]);
}, [messages, sendMessage, regenerate, clearError, atMessageCap]);
const resolveUri = useTriggerUriResolver(actionPath);
@@ -399,6 +438,7 @@ export function DashboardAgentChat({
onSelect={submit}
pageContext={clientData.pageContext}
promoted={promotedPrompt}
promptsDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
/>
) : (
<DashboardAgentMessages
@@ -406,6 +446,7 @@ export function DashboardAgentChat({
activity={activity}
error={error}
onRetry={retry}
retryDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
onDismissError={clearError}
onIntent={handleIntent}
pagePaths={pagePaths}
@@ -414,9 +455,10 @@ export function DashboardAgentChat({
/>
)}
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
{quota.kind === "reached" ? (
{atMessageCap ? (
<AgentUpgradeBlock
limit={quota.limit}
limit={messageCapLimit}
planResolved={messageCapPlanResolved}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
@@ -1,4 +1,5 @@
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
import { useEffect, useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
@@ -94,7 +95,7 @@ export function DashboardAgentComposer({
maxLength={MAX_MESSAGE_CHARS}
onChange={(e) => {
escapeGuardArmed.current = true;
onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
onChange(sliceWellFormed(e.target.value, MAX_MESSAGE_CHARS));
}}
onBlur={() => {
escapeGuardArmed.current = true;
@@ -1,8 +1,10 @@
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useCallback, useMemo, useState } from "react";
import { AgentUpgradeBlock } from "./AgentUpgradeGate";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentHero } from "./DashboardAgentHero";
import { MESSAGE_QUOTA_REACHED_REASON } from "./message-quota";
import type { AgentPageContext } from "./page-context-types";
import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
@@ -16,6 +18,7 @@ export function DashboardAgentDraft({
pageContext,
promotedPrompt,
watchCard,
capReached,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
@@ -24,6 +27,7 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
watchCard?: React.ReactNode;
capReached?: { limit: number; planResolved: boolean } | null;
}) {
const [input, setInput] = useState("");
@@ -43,12 +47,14 @@ export function DashboardAgentDraft({
const submit = useCallback(
(text: string) => {
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
if (capReached) return;
const trimmed = text.trim();
if (!trimmed) return;
setInput("");
onSubmit(trimmed);
},
[onSubmit]
[onSubmit, capReached]
);
return (
@@ -56,26 +62,44 @@ export function DashboardAgentDraft({
onSelect={submit}
pageContext={pageContext}
promoted={promotedPrompt}
promptsDisabledReason={capReached ? MESSAGE_QUOTA_REACHED_REASON : undefined}
composer={
<div className="flex w-full flex-col gap-3">
{watchCard}
<DashboardAgentComposer
layout="hero"
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
capReached ? (
<div className="flex w-full flex-col gap-3">
{watchCard}
<AgentUpgradeBlock
limit={capReached.limit}
planResolved={capReached.planResolved}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
) : (
<div className="flex w-full flex-col gap-3">
{watchCard}
<DashboardAgentComposer
layout="hero"
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
)
}
/>
);
@@ -11,6 +11,7 @@ export function DashboardAgentHero({
promoted,
dismissedIds,
composer,
promptsDisabledReason,
}: {
/** Receives the prompt text to send, not the button label. */
onSelect: (prompt: string) => void;
@@ -18,6 +19,8 @@ export function DashboardAgentHero({
promoted?: SuggestedPrompt;
dismissedIds?: string[];
composer?: React.ReactNode;
/** Set to disable the suggestion chips and say why. */
promptsDisabledReason?: string;
}) {
// Centred by the child's `m-auto`, not by `justify-center`: auto margins give up their space
// once the content outgrows the panel, so the heading stays scrollable to.
@@ -40,6 +43,7 @@ export function DashboardAgentHero({
pageContext={pageContext}
promoted={promoted}
dismissedIds={dismissedIds}
disabledReason={promptsDisabledReason}
/>
</div>
</div>
@@ -35,6 +35,8 @@ export type DashboardAgentMessagesProps = {
activity: TurnActivity | null;
error?: Error;
onRetry?: () => void;
/** Set to disable the retry button and say why, e.g. over the message cap. */
retryDisabledReason?: string;
onDismissError?: () => void;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
@@ -356,6 +358,7 @@ export function DashboardAgentTurns({
activity,
error,
onRetry,
retryDisabledReason,
onDismissError,
onIntent,
resolveUri,
@@ -402,7 +405,16 @@ export function DashboardAgentTurns({
(onRetry || onDismissError) && (
<ChatActionsRow>
{onRetry && (
<Button variant="primary/small" LeadingIcon={ArrowPathIcon} onClick={onRetry}>
<Button
variant="primary/small"
LeadingIcon={ArrowPathIcon}
onClick={onRetry}
disabled={!!retryDisabledReason}
tooltip={retryDisabledReason}
aria-label={
retryDisabledReason ? `Try again — ${retryDisabledReason}` : undefined
}
>
Try again
</Button>
)}
@@ -24,6 +24,11 @@ import {
writeLastChat,
} from "./last-chat-storage";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import {
parseQuotaReachedResponse,
shouldClearCapReached,
type MessageQuota,
} from "./message-quota";
import { WatchCard } from "./WatchCard";
import { watchDraftFor } from "./watch-card";
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
@@ -114,6 +119,10 @@ export function DashboardAgentPanel({
// Until the list has arrived, the page load's server count is the better answer.
const [chatsLoaded, setChatsLoaded] = useState(false);
const [active, setActive] = useState<ActiveChat | null>(null);
// A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast.
const [capReached, setCapReached] = useState<{ limit: number; planResolved: boolean } | null>(
null
);
// Starts true so an `openWith` request waits for the restore instead of racing it.
const [loading, setLoading] = useState(
() => readLastChat(storageKey)?.path === location.pathname
@@ -200,6 +209,8 @@ export function DashboardAgentPanel({
// half-configured watch card, which would otherwise be submitted against the new chat.
const claimChatSlot = useCallback(() => {
dispatchWatchCard({ type: "chat-changed" });
// A new attempt goes back to the server, which re-refuses if the cap still stands.
setCapReached(null);
// A request belongs to the chat it was made in: the remounting chat has a fresh guard ref,
// so a kept request would be sent a second time.
setSendRequest(undefined);
@@ -260,14 +271,22 @@ export function DashboardAgentPanel({
publicAccessToken?: string;
headStarted?: boolean;
error?: string;
limit?: number;
};
if (seq !== openChatRequestSeq.current) return;
if (!res.ok || !data.chatId || !data.publicAccessToken) {
const reached = parseQuotaReachedResponse(res.status, data);
if (reached) {
setCapReached(reached);
setActive(null);
return;
}
console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error);
toast.error(data.error ?? "We couldn't start that chat. Try again in a moment.");
setActive(null);
return;
}
setCapReached(null);
setActive({
chatId: data.chatId,
organizationId: organization.id,
@@ -309,6 +328,7 @@ export function DashboardAgentPanel({
panelOrg.current = organization.id;
claimChatSlot();
setActive(null);
setCapReached(null);
setLoading(false);
setChats([]);
setChatsLoaded(false);
@@ -477,6 +497,11 @@ export function DashboardAgentPanel({
setActive(null);
}, [claimChatSlot]);
// Released only by a read that proves capacity: an unknown quota keeps the block.
const handleQuotaChange = useCallback((quota: MessageQuota) => {
if (shouldClearCapReached(quota)) setCapReached(null);
}, []);
const switchChat = useCallback(
(id: string) => {
void openChat(id);
@@ -624,6 +649,7 @@ export function DashboardAgentPanel({
// The generated chat name is written before the turn-complete chunk lands.
onTurnSettled={loadHistory}
onActivityChange={handleActivityChange}
onQuotaChange={handleQuotaChange}
/>
) : (
<DashboardAgentDraft
@@ -634,6 +660,7 @@ export function DashboardAgentPanel({
pageContext={pageContext}
promotedPrompt={promotedPrompt}
watchCard={watchCardElement}
capReached={capReached}
/>
)}
</AgentPanelColumn>
@@ -35,6 +35,7 @@ export function DashboardAgentSuggestedPrompts({
pageContext,
promoted,
dismissedIds,
disabledReason,
}: {
/** Receives the prompt text to send, not the button label. */
onSelect: (prompt: string) => void;
@@ -43,6 +44,8 @@ export function DashboardAgentSuggestedPrompts({
promoted?: SuggestedPrompt;
/** Omitted means the component reads its own localStorage. */
dismissedIds?: string[];
/** Set to disable every chip and say why, e.g. over the message cap. */
disabledReason?: string;
}) {
// Read once on mount: re-reading per render churns the resolved set.
const [storedDismissedIds] = useState<string[]>(() =>
@@ -70,6 +73,9 @@ export function DashboardAgentSuggestedPrompts({
variant={style.variant}
LeadingIcon={style.icon}
onClick={() => onSelect(prompt.prompt)}
disabled={!!disabledReason}
tooltip={disabledReason}
aria-label={disabledReason ? `${prompt.label}${disabledReason}` : undefined}
>
{prompt.label}
</Button>
@@ -0,0 +1,32 @@
import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts";
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
import { AGENT_CHART_PLOT_CLASS, ChartActions } from "../../AgentChart";
import { AgentCard, AgentCardHeader } from "../../agent-card";
import { demoChart } from "../fixtures/chart";
export function DemoChartCard({
title = demoChart.title,
actions,
onIntent,
}: {
title?: string;
actions?: ChartAction[];
onIntent?: (intent: AgentIntent) => void;
}) {
return (
<AgentCard>
{title ? (
<AgentCardHeader className="text-xs font-medium text-text-dimmed">{title}</AgentCardHeader>
) : null}
<div className={AGENT_CHART_PLOT_CLASS}>
<QueryResultsChart
rows={demoChart.rows}
columns={demoChart.columns}
config={demoChart.config}
timeRange={demoChart.timeRange}
/>
</div>
<ChartActions actions={actions ?? []} onIntent={onIntent} />
</AgentCard>
);
}
@@ -0,0 +1,60 @@
import {
ArrowTopRightOnSquareIcon,
CheckCircleIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
import { AgentStatusIcon } from "../../agent-badges";
import { ChatStatusLine } from "../../chat-layout";
import type { DemoIntent } from "../fixtures/intents";
export function DemoIntentBubble({
intent,
onIntercept,
}: {
intent: DemoIntent;
onIntercept?: (message: string) => void;
}) {
const rejected = !intent.executable;
return (
<div
className={cn(
"rounded-md border px-3 py-3",
rejected
? "border-border-bright bg-background-bright/40"
: "border-indigo-500/30 bg-indigo-500/5"
)}
>
<ChatStatusLine
icon={
<AgentStatusIcon
tone={rejected ? "error" : "success"}
icon={rejected ? NoSymbolIcon : CheckCircleIcon}
className="mt-px"
/>
}
>
<p className="text-xs text-text-bright">{intent.outcome}</p>
{intent.deepLinkLabel ? (
<Button
variant="secondary/small"
LeadingIcon={ArrowTopRightOnSquareIcon}
onClick={() =>
onIntercept?.(
`would navigate to ${intent.deepLinkLabel} (${
intent.intent.kind === "navigate" ? intent.intent.target : intent.intent.kind
})`
)
}
>
<span className="break-all text-left font-mono text-[10px]">
{intent.deepLinkLabel}
</span>
</Button>
) : null}
</ChatStatusLine>
</div>
);
}
@@ -0,0 +1,352 @@
import {
agentIntentSchema,
agentPageContextSchema,
isRevisableBlock,
safeParseStoredViewBlock,
safeParseTriggerUri,
suggestedPromptSchema,
viewBlockSchema,
watchIdentity,
watchSpecSchema,
SUGGESTED_PROMPT_CAP,
type Evidence,
} from "@internal/dashboard-agent-contracts";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveSuggestedPrompts } from "../suggested-prompts";
import * as fixtures from "./fixtures";
import { DEMO_ID_PREFIX, DEMO_MARKER, demoSourceUri } from "./ids";
const DEMO_DIR = __dirname;
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? walk(path) : [path];
});
}
const sourceFiles = walk(DEMO_DIR).filter(
(path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts")
);
function importSpecifiers(source: string): string[] {
return [
...source.matchAll(/(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g),
// A side-effect or dynamic import binds nothing, so it never reaches a `from`.
...source.matchAll(/\bimport\s*\(?\s*["']([^"']+)["']/g),
].map((match) => match[1]!);
}
describe("demo ids", () => {
it("namespaces investigation, hypothesis, watch and prompt ids", () => {
for (const investigation of Object.values(fixtures.demoInvestigations)) {
expect(investigation.investigationId.startsWith(DEMO_ID_PREFIX)).toBe(true);
for (const hypothesis of investigation.hypotheses) {
expect(hypothesis.id.startsWith(DEMO_ID_PREFIX)).toBe(true);
}
}
for (const watch of fixtures.demoWatches.row) {
expect(watch.id.startsWith(DEMO_ID_PREFIX)).toBe(true);
}
for (const prompts of Object.values(fixtures.demoPromptSets)) {
for (const prompt of prompts) {
expect(prompt.id.startsWith(DEMO_ID_PREFIX)).toBe(true);
}
}
});
it("marks every resource id, so nothing can pass for a real one", () => {
for (const value of Object.values(fixtures.demoViewBlocks)) {
if (value.type === "diagnosis") {
expect(value.runId).toContain(DEMO_MARKER);
}
}
for (const id of Object.values({
failedRunId: fixtures.demoInvestigationConcluded.runId,
slowRunId: fixtures.demoInvestigationInconclusive.runId,
})) {
expect(id).toContain(DEMO_MARKER);
}
});
it("rejects a zero source line instead of dropping it", () => {
expect(() => demoSourceUri("abc", "src/a.ts", 0)).toThrow(/positive integer/);
expect(demoSourceUri("abc", "src/a.ts", 42)).toContain("?line=42");
expect(demoSourceUri("abc", "src/a.ts")).not.toContain("line=");
});
});
describe("view block fixtures", () => {
const blocks = Object.values(fixtures.demoViewBlocks);
it("parses every block through the lenient stored-block schema", () => {
for (const block of blocks) {
const result = safeParseStoredViewBlock(block);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
}
});
it("parses the enveloped blocks through the strict schema too", () => {
for (const block of [
fixtures.demoDiagnosisBlockFirstPass,
fixtures.demoDiagnosisBlockRevised,
fixtures.demoChartBlock,
]) {
expect(viewBlockSchema.safeParse(block).success).toBe(true);
expect(isRevisableBlock(block)).toBe(true);
}
});
it("keeps one legacy, envelope-less block that is not revisable", () => {
const legacy = fixtures.demoLegacyDiagnosisBlock;
expect(viewBlockSchema.safeParse(legacy).success).toBe(false);
expect(safeParseStoredViewBlock(legacy).success).toBe(true);
expect(isRevisableBlock(legacy)).toBe(false);
});
it("revises a block by id rather than emitting a second one", () => {
expect(fixtures.demoDiagnosisBlockRevised.id).toBe(fixtures.demoDiagnosisBlockFirstPass.id);
expect(fixtures.demoDiagnosisBlockRevised.revision).toBeGreaterThan(
fixtures.demoDiagnosisBlockFirstPass.revision
);
});
});
describe("investigation fixtures", () => {
const investigations = Object.values(fixtures.demoInvestigations);
const allEvidence = (): Evidence[] =>
investigations.flatMap((investigation) => [
...investigation.evidence,
...investigation.hypotheses.flatMap((hypothesis) => hypothesis.evidence),
]);
it("cites only valid trigger:// URIs, with the kind matching the URI", () => {
for (const evidence of allEvidence()) {
const parsed = safeParseTriggerUri(evidence.uri);
expect(parsed.success, `${evidence.uri}: ${!parsed.success ? parsed.error : ""}`).toBe(true);
if (parsed.success) expect(parsed.data.kind).toBe(evidence.kind);
expect(evidence.uri).toContain(DEMO_MARKER);
}
});
it("only offers a fix when it concluded, and only 'check next' when it didn't", () => {
for (const investigation of investigations) {
if (investigation.outcome === "concluded") {
expect(investigation.remediation).toBeTruthy();
expect(investigation.checkNext).toBeUndefined();
} else {
expect(investigation.remediation).toBeUndefined();
}
if (investigation.outcome === "inconclusive") {
expect(investigation.checkNext?.length).toBeGreaterThan(0);
}
}
});
it("gives the concluded card at least two settled hypotheses", () => {
const settled = fixtures.demoInvestigationConcluded.hypotheses.filter(
(hypothesis) => hypothesis.verdict !== "testing"
);
expect(settled.length).toBeGreaterThanOrEqual(2);
expect(settled.some((h) => h.verdict === "validated")).toBe(true);
expect(settled.some((h) => h.verdict === "invalidated")).toBe(true);
expect(
fixtures.demoInvestigationConcluded.hypotheses.every(
(h) => h.verdict === "testing" || h.finding
)
).toBe(true);
});
it("keeps a streaming revision with a hypothesis still testing", () => {
expect(fixtures.demoInvestigationStreamingRev1.investigationId).toBe(
fixtures.demoInvestigationStreamingRev0.investigationId
);
expect(fixtures.demoInvestigationStreamingRev1.revision).toBeGreaterThan(
fixtures.demoInvestigationStreamingRev0.revision
);
expect(
fixtures.demoInvestigationStreamingRev1.hypotheses.some((h) => h.verdict === "testing")
).toBe(true);
});
it("hedges the dirty-commit variant with the agreed wording", () => {
expect(fixtures.demoInvestigationDirtyCommit.caveat?.kind).toBe("dirty_commit");
expect(fixtures.demoInvestigationDirtyCommit.caveat?.message).toContain(
"nearest repository snapshot"
);
});
it("cites file:line@sha in the show-code turn", () => {
expect(fixtures.demoShowCodeMarkdown).toMatch(/\.ts:\d+(-\d+)?@[0-9a-z]{7}/);
expect(fixtures.demoShowCodeMarkdown).toContain("```diff");
});
});
describe("watch fixtures", () => {
it("validates every spec against the contracts schema", () => {
for (const watch of fixtures.demoWatches.row) {
const result = watchSpecSchema.safeParse(watch.spec);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
}
});
it("derives the chip identity from the spec", () => {
for (const watch of fixtures.demoWatches.row) {
expect(watch.identity).toBe(watchIdentity(watch.spec));
}
});
it("covers every watch status and offers cancel only while active", () => {
const statuses = new Set(fixtures.demoWatches.row.map((watch) => watch.status));
expect(statuses).toEqual(new Set(["active", "fired", "expired", "cancelled"]));
for (const watch of fixtures.demoWatches.row) {
expect(watch.cancellable).toBe(watch.status === "active");
}
});
it("has an expiry narration that admits it could not verify", () => {
expect(fixtures.demoWatchNarration.expiryUnverified).toContain("couldn't verify");
});
});
describe("intent fixtures", () => {
it("validates every intent and marks propose_fix non-executable", () => {
for (const demoIntent of Object.values(fixtures.demoIntents)) {
const result = agentIntentSchema.safeParse(demoIntent.intent);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect(demoIntent.executable).toBe(demoIntent.intent.kind !== "propose_fix");
}
});
it("points the filtered-runs example at the runs collection, not one run", () => {
const target = fixtures.demoIntents.navigateToFailedRuns.intent;
expect(target.kind).toBe("navigate");
if (target.kind !== "navigate") return;
const parsed = safeParseTriggerUri(target.target);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.kind).toBe("runs");
});
});
describe("page context and prompt fixtures", () => {
it("validates every page context", () => {
for (const context of Object.values(fixtures.demoPageContexts)) {
const result = agentPageContextSchema.safeParse(context);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
}
});
it("covers all four signal kinds", () => {
const kinds = new Set(fixtures.demoSignalsByPriority.map((signal) => signal.kind));
expect(kinds).toEqual(
new Set(["fresh_failure", "waiting_run", "slow_run", "concurrency_saturation"])
);
});
it("validates every chip, stays under the cap, and promotes at most one", () => {
for (const prompts of Object.values(fixtures.demoPromptSets)) {
expect(prompts.length).toBeLessThanOrEqual(SUGGESTED_PROMPT_CAP);
expect(prompts.filter((prompt) => prompt.source === "promoted").length).toBeLessThanOrEqual(
1
);
for (const prompt of prompts) {
expect(suggestedPromptSchema.safeParse(prompt).success).toBe(true);
}
}
});
it("drops dismissed chips from the resolved row", () => {
for (const id of fixtures.demoDismissedPromptIds) {
expect(fixtures.demoPromptsAfterDismissal.some((prompt) => prompt.id === id)).toBe(false);
}
});
it("dismisses a chip the resolver actually emits", () => {
const full = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext);
const after = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext, {
dismissedIds: fixtures.demoResolvedDismissedPromptIds,
});
expect(full.map((prompt) => prompt.id)).toContain(fixtures.demoResolvedDismissedPromptIds[0]);
expect(after.map((prompt) => prompt.id)).not.toEqual(full.map((prompt) => prompt.id));
});
});
describe("report fixtures", () => {
it("covers a healthy and a degraded verdict", () => {
expect(fixtures.demoHealthyReport.summary.severity).toBe("ok");
expect(fixtures.demoDegradedReport.summary.severity).toBe("crit");
});
it("references only metrics the report carries, and only links it declares", () => {
for (const vm of Object.values(fixtures.demoReports)) {
const metricIds = new Set(vm.metrics.map((metric) => metric.id));
for (const finding of vm.findings) {
for (const id of finding.metricIds) expect(metricIds.has(id), id).toBe(true);
}
const linkKeys = new Set(vm.links.map((link) => link.key));
for (const entry of vm.footer) {
if (entry.link) expect(linkKeys.has(entry.link), entry.link).toBe(true);
}
expect(vm.footer.length).toBeLessThanOrEqual(3);
}
});
});
describe("chart fixtures", () => {
it("has a row for every configured column", () => {
const columns = fixtures.demoChart.columns.map((column) => column.name);
for (const row of fixtures.demoChart.rows) {
expect(Object.keys(row).sort()).toEqual([...columns].sort());
}
expect(columns).toContain(fixtures.demoChart.config.xAxisColumn);
for (const y of fixtures.demoChart.config.yAxisColumns) expect(columns).toContain(y);
});
});
describe("isolation", () => {
it("reads every form an import can take, including the ones that bind nothing", () => {
const source = [
`import { a } from "./a";`,
`import "~/db.server";`,
`import type { B } from "./b";`,
`export * from "./c";`,
`const d = await import("~/routes/thing");`,
`import("./lazy").then((m) => m.go());`,
].join("\n");
expect(importSpecifiers(source).sort()).toEqual([
"./a",
"./b",
"./c",
"./lazy",
"~/db.server",
"~/routes/thing",
]);
});
it("imports no server module and no route", () => {
for (const path of sourceFiles) {
const specifiers = importSpecifiers(readFileSync(path, "utf8"));
for (const specifier of specifiers) {
expect(specifier.includes(".server"), `${path} -> ${specifier}`).toBe(false);
expect(/routes?\//.test(specifier), `${path} -> ${specifier}`).toBe(false);
expect(specifier.includes("~/db"), `${path} -> ${specifier}`).toBe(false);
}
}
});
it("makes no network calls", () => {
for (const path of sourceFiles) {
const source = readFileSync(path, "utf8");
expect(/\bfetch\s*\(/.test(source), path).toBe(false);
expect(/\buseFetcher\b/.test(source), path).toBe(false);
}
});
it("has no server file of its own", () => {
expect(sourceFiles.filter((path) => path.endsWith(".server.ts"))).toEqual([]);
});
});
@@ -0,0 +1,126 @@
import {
VIEW_BLOCK_VERSION,
type EnvelopedChartBlock,
type EnvelopedDiagnosisBlock,
type ViewBlock,
} from "@internal/dashboard-agent-contracts";
import { demoId, demoRunsUri, DEMO_WORLD } from "../ids";
const envelope = (id: string, revision = 0) => ({
id: demoId(id),
revision,
version: VIEW_BLOCK_VERSION,
});
export const demoDiagnosisBlockFirstPass: EnvelopedDiagnosisBlock = {
...envelope("diagnosis-order-receipt", 0),
type: "diagnosis",
runId: DEMO_WORLD.failedRunId,
summary: `${DEMO_WORLD.taskId} failed while calling the email provider. The call came back 429 and the run exhausted its 3 retries.`,
category: "rate_limit",
likelyCause:
"The email provider is rate limiting this API key. All three attempts landed inside the same 20-second window, so the retries never had a chance to clear the limit.",
confidence: "medium",
evidence: [
{
type: "error",
detail: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)",
reference: DEMO_WORLD.failedRunId,
},
{
type: "failed_span",
detail: "sendEmail span failed after 412ms on attempt 3 of 3",
reference: DEMO_WORLD.failedSpanId,
},
],
nextSteps: [
"Spread the retries out: raise the retry delay so attempts don't land in the same rate-limit window.",
"Cap concurrency on the queue so the task can't burst past the provider's per-second limit.",
],
};
export const demoDiagnosisBlockRevised: EnvelopedDiagnosisBlock = {
...demoDiagnosisBlockFirstPass,
...envelope("diagnosis-order-receipt", 1),
summary: `${DEMO_WORLD.taskId} failed because the email provider rate limited it. 41 runs on this queue hit the same 429 in the last hour — this run isn't special.`,
confidence: "high",
impact: `41 runs of ${DEMO_WORLD.taskId} failed the same way in the last hour, all on the ${DEMO_WORLD.queue} queue.`,
evidence: [
...demoDiagnosisBlockFirstPass.evidence,
{
type: "historical_match",
detail: "41 runs failed with the same error fingerprint in the last hour",
reference: DEMO_WORLD.errorFingerprint,
},
{
type: "source",
detail: "retry.maxAttempts is 3 with a 1s base delay and no jitter",
reference: `${DEMO_WORLD.sourcePath}:18`,
},
],
nextSteps: [
"Raise the retry delay (or add jitter) so attempts don't all land inside one rate-limit window.",
`Cap concurrency on ${DEMO_WORLD.queue} to stay under the provider's per-second limit.`,
"Consider a queue-level rate limit so a backlog can't burst into the provider.",
],
actions: [
{ label: "View run", kind: "view_run", target: DEMO_WORLD.failedRunId },
{
label: "Read the retries docs",
kind: "docs",
target: "https://trigger.dev/docs/errors-retrying",
},
],
};
export const demoChartBlock: EnvelopedChartBlock = {
...envelope("chart-failures-by-task", 0),
type: "chart",
title: "Failed runs per hour, by task",
query:
"SELECT toStartOfHour(created_at) AS hour, task_identifier, countIf(status = 'COMPLETED_WITH_ERROR') AS failures FROM task_runs GROUP BY hour, task_identifier ORDER BY hour",
period: "24h",
chartType: "line",
xAxisColumn: "hour",
yAxisColumns: ["failures"],
groupByColumn: "task_identifier",
stacked: false,
aggregation: "sum",
actions: [
{
label: `Investigate ${DEMO_WORLD.taskId}`,
intent: {
kind: "ask",
prompt: `Investigate the ${DEMO_WORLD.taskId} failures — why are they failing?`,
},
},
{
label: "See its failed runs",
intent: {
kind: "navigate",
target: demoRunsUri(),
filters: { tasks: [DEMO_WORLD.taskId], statuses: ["COMPLETED_WITH_ERROR"], period: "1d" },
},
},
],
};
// No envelope on purpose: the pre-envelope transcript path must still render.
export const demoLegacyDiagnosisBlock: ViewBlock = {
type: "diagnosis",
runId: DEMO_WORLD.priorRunId,
summary:
"This run failed the same way three weeks ago, before the panel stamped identity onto its cards.",
category: "rate_limit",
likelyCause: "The email provider rate limited the same API key.",
confidence: "medium",
evidence: [{ type: "error", detail: "ProviderError: 429 Too Many Requests" }],
nextSteps: ["Nothing to do — kept as a fixture for the pre-envelope render path."],
};
export const demoViewBlocks = {
diagnosisFirstPass: demoDiagnosisBlockFirstPass,
diagnosisRevised: demoDiagnosisBlockRevised,
chart: demoChartBlock,
legacyDiagnosis: demoLegacyDiagnosisBlock,
} as const;
@@ -0,0 +1,50 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
export const demoChartColumns: OutputColumnMetadata[] = [
{ name: "hour", type: "DateTime" },
{ name: "task_identifier", type: "String" },
{ name: "failures", type: "UInt64", format: "quantity" },
];
const SERIES: Record<string, number[]> = {
"send-order-receipt": [1, 0, 2, 1, 3, 2, 4, 9, 14, 22, 31, 41],
"generate-monthly-report": [0, 1, 0, 0, 1, 0, 2, 1, 0, 1, 2, 1],
"sync-crm-contacts": [3, 2, 4, 3, 2, 3, 2, 4, 3, 2, 3, 2],
};
const START_MS = Date.parse("2026-07-26T23:00:00.000Z");
const HOUR_MS = 3_600_000;
export const demoChartRows: Record<string, unknown>[] = Object.entries(SERIES).flatMap(
([task, points]) =>
points.map((failures, i) => ({
hour: new Date(START_MS + i * HOUR_MS).toISOString(),
task_identifier: task,
failures,
}))
);
export const demoChartConfig: ChartConfiguration = {
chartType: "line",
xAxisColumn: "hour",
yAxisColumns: ["failures"],
groupByColumn: "task_identifier",
stacked: false,
sortByColumn: null,
sortDirection: "desc",
aggregation: "sum",
};
export const demoChartTimeRange = {
from: new Date(START_MS).toISOString(),
to: new Date(START_MS + 11 * HOUR_MS).toISOString(),
};
export const demoChart = {
rows: demoChartRows,
columns: demoChartColumns,
config: demoChartConfig,
timeRange: demoChartTimeRange,
title: "Failed runs per hour, by task",
} as const;
@@ -0,0 +1,8 @@
export * from "./blocks";
export * from "./chart";
export * from "./intents";
export * from "./investigation";
export * from "./messages";
export * from "./page-context";
export * from "./reports";
export * from "./watches";
@@ -0,0 +1,60 @@
import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts";
import { DEMO_WORLD, demoRunUri, demoRunsUri } from "../ids";
import { demoBacklogDrainWatch } from "./watches";
export type DemoIntent = {
intent: AgentIntent;
outcome: string;
deepLinkLabel?: string;
executable: boolean;
};
const demoIntent = (intent: AgentIntent, outcome: string, deepLinkLabel?: string): DemoIntent => ({
intent,
outcome,
deepLinkLabel,
executable: isExecutableIntent(intent),
});
export const demoNavigateToFailedRuns = demoIntent(
{
kind: "navigate",
target: demoRunsUri(),
filters: {
statuses: ["COMPLETED_WITH_ERROR"],
period: "24h",
tasks: [DEMO_WORLD.taskId],
},
},
"Opened runs filtered to failed · last 24h · send-order-receipt",
"/runs?statuses=COMPLETED_WITH_ERROR&period=24h&tasks=send-order-receipt"
);
export const demoNavigateToRun = demoIntent(
{ kind: "navigate", target: demoRunUri(DEMO_WORLD.failedRunId) },
`Opened ${DEMO_WORLD.failedRunId}`,
`/runs/${DEMO_WORLD.failedRunId}`
);
export const demoAskIntent = demoIntent(
{ kind: "ask", prompt: "Do you want me to watch the retry and tell you when it finishes?" },
"Asked a follow-up"
);
export const demoWatchIntent = demoIntent(
{ kind: "watch", spec: demoBacklogDrainWatch.spec },
`Watching ${DEMO_WORLD.backlogQueue} · checking every 5 min for up to 6h`
);
export const demoProposeFixIntent = demoIntent(
{ kind: "propose_fix", investigationId: "demo:investigation-order-receipt" },
"Rejected: proposing a fix isn't available yet"
);
export const demoIntents = {
navigateToFailedRuns: demoNavigateToFailedRuns,
navigateToRun: demoNavigateToRun,
ask: demoAskIntent,
watch: demoWatchIntent,
proposeFix: demoProposeFixIntent,
} as const;
@@ -0,0 +1,438 @@
// Block `id` is the `investigationId` and `revision` climbs. The contracts package freezes that.
import type { Evidence } from "@internal/dashboard-agent-contracts";
import {
DEMO_WORLD,
demoDeploymentUri,
demoErrorUri,
demoId,
demoQueueUri,
demoRunUri,
demoSourceUri,
demoSpanUri,
} from "../ids";
export type DemoHypothesisVerdict = "testing" | "validated" | "invalidated";
export type DemoHypothesis = {
id: string;
statement: string;
verdict: DemoHypothesisVerdict;
finding?: string;
evidence: Evidence[];
};
export type DemoInvestigationOutcome = "in_progress" | "concluded" | "inconclusive";
export type DemoInvestigationSeverity = "info" | "warn" | "crit";
export type DemoInvestigationCaveat = {
kind: "dirty_commit";
message: string;
};
export type DemoInvestigation = {
investigationId: string;
revision: number;
outcome: DemoInvestigationOutcome;
severity: DemoInvestigationSeverity;
confidence: "high" | "medium" | "low";
runId?: string;
title: string;
headline: string;
remediation?: string;
checkNext?: string[];
progress?: string;
hypotheses: DemoHypothesis[];
evidence: Evidence[];
caveat?: DemoInvestigationCaveat;
startedAt: string;
updatedAt: string;
};
const runUri = demoRunUri(DEMO_WORLD.failedRunId);
const spanUri = demoSpanUri(DEMO_WORLD.failedRunId, DEMO_WORLD.failedSpanId);
const errorUri = demoErrorUri(DEMO_WORLD.errorFingerprint);
const queueUri = demoQueueUri(DEMO_WORLD.queue);
const sourceUri = demoSourceUri(DEMO_WORLD.sourceSha, DEMO_WORLD.sourcePath, 18);
const INVESTIGATION_ID = demoId("investigation-order-receipt");
const errorEvidence: Evidence = {
kind: "error",
uri: errorUri,
label: "rate_limit_exceeded · 41 runs in the last hour",
excerpt: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)",
};
const spanEvidence: Evidence = {
kind: "span",
uri: spanUri,
label: "sendEmail span, attempt 3 of 3",
excerpt: "sendEmail 412ms ✕ 429 Too Many Requests",
};
const sourceEvidence: Evidence = {
kind: "source",
uri: sourceUri,
label: `${DEMO_WORLD.sourcePath}:18`,
excerpt: "retry: { maxAttempts: 3, minTimeoutInMs: 1_000, factor: 1 },",
};
const queueEvidence: Evidence = {
kind: "queue",
uri: queueUri,
label: `${DEMO_WORLD.queue} · concurrency 50 of 50`,
excerpt: "concurrency pinned at 50 for 38 of the last 60 min",
};
const runEvidence: Evidence = {
kind: "run",
uri: runUri,
label: `${DEMO_WORLD.failedRunId} · failed after 3 attempts`,
excerpt: "attempt 1 429 · attempt 2 429 · attempt 3 429 — all within 19.4s",
};
const priorRunEvidence: Evidence = {
kind: "run",
uri: demoRunUri(DEMO_WORLD.priorRunId),
label: `${DEMO_WORLD.priorRunId} · same payload, completed in 1.2s`,
excerpt: "2,104 runs with this payload shape succeeded earlier today",
};
const deploymentEvidence: Evidence = {
kind: "deployment",
uri: demoDeploymentUri(DEMO_WORLD.deploymentVersion),
label: `${DEMO_WORLD.deploymentVersion} · deployed 19h before the first failure`,
excerpt: "first failure 09:02, deploy 14:11 the previous day — no overlap",
};
export const demoInvestigationStreamingRev0: DemoInvestigation = {
investigationId: INVESTIGATION_ID,
revision: 0,
outcome: "in_progress",
severity: "warn",
confidence: "low",
runId: DEMO_WORLD.failedRunId,
title: `Why is ${DEMO_WORLD.taskId} failing?`,
headline:
"All three attempts of this run ended in an error from the email provider. I'm reading the spans to see which call failed and whether the retries had a chance to succeed.",
progress: "Reading the run's spans",
hypotheses: [
{
id: demoId("hyp-rate-limit"),
statement: "The email provider is rate limiting this API key.",
verdict: "testing",
evidence: [],
},
{
id: demoId("hyp-bad-payload"),
statement: "The payload is malformed and the provider rejects it.",
verdict: "testing",
evidence: [],
},
{
id: demoId("hyp-retry-window"),
statement: "The retry schedule keeps every attempt inside one rate-limit window.",
verdict: "testing",
evidence: [],
},
],
evidence: [runEvidence, spanEvidence],
startedAt: "2026-07-27T10:14:02.000Z",
updatedAt: "2026-07-27T10:14:06.000Z",
};
export const demoInvestigationEarly: DemoInvestigation = {
investigationId: demoId("investigation-order-receipt-early"),
revision: 0,
outcome: "in_progress",
severity: "warn",
confidence: "low",
runId: DEMO_WORLD.failedRunId,
title: `Why is ${DEMO_WORLD.taskId} failing?`,
headline:
"The run failed after three attempts. I'm reading its spans to see which call failed before I put any hypotheses up.",
progress: "Reading the run's spans",
hypotheses: [],
evidence: [runEvidence],
startedAt: "2026-07-27T10:14:02.000Z",
updatedAt: "2026-07-27T10:14:03.000Z",
};
export const demoInvestigationStreamingRev1: DemoInvestigation = {
...demoInvestigationStreamingRev0,
revision: 1,
confidence: "medium",
headline:
"Every attempt came back 429 rate_limit_exceeded, and 41 other runs of this task hit the same error in the last hour. Checking whether the retry schedule made it worse.",
progress: "Comparing against the last hour of runs on this queue",
hypotheses: [
{
...demoInvestigationStreamingRev0.hypotheses[0]!,
verdict: "validated",
finding: "All three attempts returned 429 rate_limit_exceeded inside a 20-second window.",
evidence: [errorEvidence, spanEvidence],
},
{
...demoInvestigationStreamingRev0.hypotheses[1]!,
verdict: "invalidated",
finding:
"The same payload shape succeeded on 2,104 runs earlier today, and the provider never returned a 4xx other than 429.",
evidence: [priorRunEvidence],
},
{
...demoInvestigationStreamingRev0.hypotheses[2]!,
verdict: "testing",
evidence: [queueEvidence],
},
],
evidence: [runEvidence, errorEvidence, queueEvidence],
updatedAt: "2026-07-27T10:14:11.000Z",
};
export const demoInvestigationConcluded: DemoInvestigation = {
investigationId: INVESTIGATION_ID,
revision: 2,
outcome: "concluded",
severity: "crit",
confidence: "high",
runId: DEMO_WORLD.failedRunId,
title: `${DEMO_WORLD.taskId} is failing on every retry`,
headline:
"The email provider is rate limiting this API key, and the task's retries all land inside the same limit window — so every attempt fails. 41 runs failed this way in the last hour.",
remediation:
"Spread the attempts out and stop the queue bursting into the provider: raise `minTimeoutInMs` to 30s with a factor of 2 (or add jitter) so the three attempts span the limit window instead of sharing it, and cap the queue's concurrency at 20 to stay under the provider's per-second ceiling. Neither change needs a code deploy if you set the queue limit from the dashboard.",
hypotheses: [
{
id: demoId("hyp-rate-limit"),
statement: "The email provider is rate limiting this API key.",
verdict: "validated",
finding:
"All three attempts returned 429 rate_limit_exceeded, and 41 other runs hit the same fingerprint in the last hour.",
evidence: [errorEvidence, spanEvidence],
},
{
id: demoId("hyp-bad-payload"),
statement: "The payload is malformed and the provider rejects it.",
verdict: "invalidated",
finding:
"The same payload succeeded on 2,104 runs earlier today; the provider never returned a 4xx other than 429.",
evidence: [priorRunEvidence, runEvidence],
},
{
id: demoId("hyp-retry-window"),
statement: "The retry schedule keeps every attempt inside one rate-limit window.",
verdict: "validated",
finding:
"maxAttempts 3 with a 1s base delay and factor 1 puts all three attempts inside 20 seconds.",
evidence: [sourceEvidence, spanEvidence],
},
{
id: demoId("hyp-queue-burst"),
statement: "The queue is bursting into the provider faster than its per-second ceiling.",
verdict: "validated",
finding:
"The queue sat at its concurrency limit of 50 for 38 of the last 60 minutes, so ~50 sends land on the provider at once every time it drains.",
evidence: [queueEvidence],
},
{
id: demoId("hyp-deploy-regression"),
statement: "Yesterday's deploy introduced the failure.",
verdict: "invalidated",
finding:
"The deploy went out 19 hours before the first failure and the task ran clean for most of that window, so the timing rules it out.",
evidence: [deploymentEvidence],
},
],
evidence: [errorEvidence, spanEvidence, sourceEvidence, queueEvidence, deploymentEvidence],
startedAt: "2026-07-27T10:14:02.000Z",
updatedAt: "2026-07-27T10:14:24.000Z",
};
export const demoInvestigationConcludedNoCode: DemoInvestigation = {
investigationId: demoId("investigation-queue-saturation"),
revision: 2,
outcome: "concluded",
severity: "crit",
confidence: "high",
title: `${DEMO_WORLD.queue} is starving — nothing is starting`,
headline: `The ${DEMO_WORLD.queue} queue has sat at its concurrency limit of 50 for 38 of the last 60 minutes, so new runs wait behind the ones already running. The p95 wait is 2 minutes against a p50 of 38 seconds, and every run that does start finishes normally.`,
remediation:
"Raise the queue's concurrency limit (or the environment's, if that's the one it's hitting) until the depth trend flattens. You can set it from the queue page — no deploy needed. If the limit is deliberate, the backlog is telling you the arrival rate now exceeds it, and the trigger side is what has to change.",
hypotheses: [
{
id: demoId("hyp-queue-limit"),
statement: "Runs are waiting on the queue's concurrency limit, not failing.",
verdict: "validated",
finding:
"The queue was pinned at 50 of 50 for 38 of the last 60 minutes while the depth climbed from 10 to 4,210, and no run in the window failed.",
evidence: [queueEvidence],
},
{
id: demoId("hyp-queue-slow-task"),
statement: "The task itself got slower, so each slot is held longer.",
verdict: "invalidated",
finding:
"Runs that did start completed in ~1.2s, the same as earlier today — the slots turn over as fast as they ever did.",
evidence: [priorRunEvidence],
},
],
evidence: [queueEvidence, priorRunEvidence],
startedAt: "2026-07-27T11:02:00.000Z",
updatedAt: "2026-07-27T11:02:19.000Z",
};
export const demoInvestigationInconclusive: DemoInvestigation = {
investigationId: demoId("investigation-monthly-report"),
revision: 1,
outcome: "inconclusive",
severity: "warn",
confidence: "low",
runId: DEMO_WORLD.slowRunId,
title: `Why is ${DEMO_WORLD.slowTaskId} slow?`,
headline:
"This run has been executing for 24 minutes against a p95 of 3 minutes, and the time is spent inside one un-instrumented span. I can see where it stalls but not why — nothing in the telemetry explains it.",
checkNext: [
"Add a span (or a log) around the report aggregation step so the stall shows up in the trace.",
"Check the warehouse the aggregation reads from — a slow upstream query would look exactly like this.",
"Compare against the last run that finished normally to see whether the payload got bigger.",
],
hypotheses: [
{
id: demoId("hyp-slow-oom"),
statement: "The run is thrashing against its memory limit.",
verdict: "invalidated",
finding:
"Peak memory stayed at 38% of the machine's limit for the whole run, and there is no OOM signal on the attempt.",
evidence: [
{
kind: "run",
uri: demoRunUri(DEMO_WORLD.slowRunId),
label: `${DEMO_WORLD.slowRunId} · machine metrics, large-1x`,
excerpt: "memory peak 38% · cpu 11% avg · no restarts",
},
],
},
{
id: demoId("hyp-slow-queue-wait"),
statement: "The run spent the time waiting for a worker rather than executing.",
verdict: "invalidated",
finding:
"It was dequeued 40ms after it was triggered and has been executing ever since — the time is inside the attempt, not in front of it.",
evidence: [
{
kind: "queue",
uri: demoQueueUri(DEMO_WORLD.backlogQueue),
label: `${DEMO_WORLD.backlogQueue} · 3 of 20 concurrency in use`,
excerpt: "dequeued 40ms after trigger · no queue wait",
},
],
},
{
id: demoId("hyp-slow-upstream"),
statement: "An upstream call inside the aggregation step is blocking.",
verdict: "testing",
finding: undefined,
evidence: [
{
kind: "span",
uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"),
label: "aggregate span · 23m 41s, no children",
excerpt: "aggregate 23m41s ● (no child spans)",
},
],
},
],
evidence: [
{
kind: "run",
uri: demoRunUri(DEMO_WORLD.slowRunId),
label: `${DEMO_WORLD.slowRunId} · executing for 24m, p95 is 3m`,
excerpt: "status EXECUTING · attempt 1 · started 09:17:22",
},
{
kind: "span",
uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"),
label: "aggregate span · 23m 41s, no children",
excerpt: "aggregate 23m41s ● (no child spans)",
},
],
startedAt: "2026-07-27T09:41:00.000Z",
updatedAt: "2026-07-27T09:41:38.000Z",
};
export const demoInvestigationDegraded: DemoInvestigation = {
investigationId: demoId("investigation-order-receipt-degraded"),
revision: 1,
outcome: "inconclusive",
severity: "warn",
confidence: "low",
runId: DEMO_WORLD.failedRunId,
title: `Why is ${DEMO_WORLD.taskId} failing?`,
headline: `Every attempt of this run ended in a 429 from the email provider, and 41 other runs hit the same error in the last hour. I couldn't read the trace — the spans for this run are no longer retained — so I can't tell whether the retries all landed inside one rate-limit window, which is what would explain it.`,
checkNext: [
"Re-run the investigation on a fresher failure, while its spans are still retained.",
"Check the provider's dashboard for the rate limit on this API key and when it resets.",
"Compare the task's retry settings against that window — three attempts inside one window would fail as a group.",
],
hypotheses: [
{
id: demoId("hyp-rate-limit"),
statement: "The email provider is rate limiting this API key.",
verdict: "validated",
finding: "All three attempts returned 429 rate_limit_exceeded on the same fingerprint.",
evidence: [errorEvidence],
},
{
id: demoId("hyp-retry-window"),
statement: "The retry schedule keeps every attempt inside one rate-limit window.",
verdict: "testing",
finding: "The run's spans are no longer retained, so the attempt timings can't be read.",
evidence: [],
},
],
evidence: [runEvidence, errorEvidence],
startedAt: "2026-07-27T10:31:00.000Z",
updatedAt: "2026-07-27T10:31:14.000Z",
};
export const demoInvestigationDirtyCommit: DemoInvestigation = {
...demoInvestigationConcluded,
investigationId: demoId("investigation-order-receipt-dirty"),
confidence: "medium",
caveat: {
kind: "dirty_commit",
message:
"Source lines below come from the nearest repository snapshot, not the exact deployed code — this deploy was built from a working tree with uncommitted changes. The run, span and error evidence is unaffected.",
},
};
export const demoInvestigations = {
early: demoInvestigationEarly,
streamingRev0: demoInvestigationStreamingRev0,
streamingRev1: demoInvestigationStreamingRev1,
concluded: demoInvestigationConcluded,
concludedNoCode: demoInvestigationConcludedNoCode,
inconclusive: demoInvestigationInconclusive,
degraded: demoInvestigationDegraded,
dirtyCommit: demoInvestigationDirtyCommit,
} as const;
export const demoShowCodeMarkdown = `Here's the change, against \`${DEMO_WORLD.sourcePath}:14-20@${DEMO_WORLD.sourceSha.slice(0, 7)}\`:
\`\`\`diff
--- a/${DEMO_WORLD.sourcePath}
+++ b/${DEMO_WORLD.sourcePath}
@@ -14,7 +14,8 @@ export const sendOrderReceipt = task({
id: "${DEMO_WORLD.taskId}",
retry: {
maxAttempts: 3,
- minTimeoutInMs: 1_000,
- factor: 1,
+ minTimeoutInMs: 30_000,
+ factor: 2,
+ randomize: true,
},
\`\`\`
That spreads the three attempts across ~2 minutes instead of 20 seconds. I haven't applied anything — this is the patch I'd suggest.`;
@@ -0,0 +1,71 @@
import type { UIMessage } from "@ai-sdk/react";
import type { ViewBlock } from "@internal/dashboard-agent-contracts";
import { demoId } from "../ids";
type Part = UIMessage["parts"][number];
export function demoMessageId(name: string): string {
return demoId(`msg-${name}`);
}
export function userMessage(name: string, text: string): UIMessage {
return { id: demoMessageId(name), role: "user", parts: [{ type: "text", text }] };
}
export function assistantMessage(name: string, parts: Part[]): UIMessage {
return { id: demoMessageId(name), role: "assistant", parts };
}
export function textPart(text: string): Part {
return { type: "text", text, state: "done" };
}
export function streamingTextPart(text: string): Part {
return { type: "text", text, state: "streaming" };
}
export function reasoningPart(text: string): Part {
return { type: "reasoning", text, state: "done" };
}
export function toolPart(name: string, input: unknown, output: unknown, callName?: string): Part {
return {
type: `tool-${name}`,
toolCallId: demoId(`call-${callName ?? name}`),
state: "output-available",
input,
output,
} as Part;
}
export function pendingToolPart(name: string, input: unknown, callName?: string): Part {
return {
type: `tool-${name}`,
toolCallId: demoId(`call-${callName ?? name}`),
state: "input-available",
input,
} as Part;
}
export function failedToolPart(
name: string,
input: unknown,
errorText: string,
callName?: string
): Part {
return {
type: `tool-${name}`,
toolCallId: demoId(`call-${callName ?? name}`),
state: "output-error",
input,
errorText,
} as Part;
}
export function renderViewPart(blocks: ViewBlock[], callName?: string): Part {
return toolPart("render_view", { blocks }, { blocks }, callName ?? "render-view");
}
export function sourceUrlPart(url: string, title: string): Part {
return { type: "source-url", sourceId: demoId(`source-${title}`), url, title } as Part;
}
@@ -238,6 +238,12 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")];
/**
* Ids the resolver itself emits, for dismissing against a live resolve. Dismissing the
* fresh-failure chip on `demoFailedRunPageContext` falls back to `sp:run-investigate`.
*/
export const demoResolvedDismissedPromptIds: string[] = ["sp:fresh-failure"];
export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun
.filter((p) => !demoDismissedPromptIds.includes(p.id))
.slice(0, SUGGESTED_PROMPT_CAP);
@@ -0,0 +1,149 @@
import {
watchIdentity,
type WatchSpec,
type WatchStatus,
} from "@internal/dashboard-agent-contracts";
import { DEMO_WORLD, demoId } from "../ids";
export type DemoWatch = {
id: string;
spec: WatchSpec;
status: WatchStatus;
identity: string;
chipLabel: string;
createdAt: string;
expiresAt: string;
cancellable: boolean;
};
const watch = (
name: string,
spec: WatchSpec,
chipLabel: string,
status: WatchStatus,
createdAt: string,
expiresAt: string
): DemoWatch => ({
id: demoId(`watch-${name}`),
spec,
status,
identity: watchIdentity(spec),
chipLabel,
createdAt,
expiresAt,
cancellable: status === "active",
});
export const demoRunFinishedWatch = watch(
"run-finished",
{
kind: "run_finished",
runId: DEMO_WORLD.failedRunId,
note: "Tell me when the retry of send-order-receipt finishes.",
maxHours: 2,
checkEveryMinutes: 1,
},
DEMO_WORLD.taskId,
"active",
"2026-07-27T10:15:10.000Z",
"2026-07-27T12:15:10.000Z"
);
export const demoBacklogDrainWatch = watch(
"backlog-drain",
{
kind: "backlog_drain",
queue: DEMO_WORLD.backlogQueue,
note: "Tell me when the backlog on demo-backlog-drain clears.",
maxHours: 6,
checkEveryMinutes: 5,
},
"backlog-drain",
"active",
"2026-07-27T09:02:00.000Z",
"2026-07-27T15:02:00.000Z"
);
export const demoErrorRecurrenceWatch = watch(
"email-sends",
{
kind: "error_recurrence",
// The page cites `error_<fingerprint>`, the spec keeps the bare form.
fingerprint: DEMO_WORLD.errorFingerprint.replace(/^error_/, ""),
note: "Tell me if the rate-limit error comes back.",
maxHours: 12,
checkEveryMinutes: 15,
},
"email-sends",
"fired",
"2026-07-26T22:40:00.000Z",
"2026-07-27T10:40:00.000Z"
);
export const demoHealthRecoveryWatch = watch(
"health-recovery",
{
kind: "health_recovery",
report: "health",
fromSeverity: "crit",
note: "Tell me when prod is healthy again.",
maxHours: 4,
checkEveryMinutes: 15,
},
"health-recovery",
"expired",
"2026-07-27T04:20:00.000Z",
"2026-07-27T08:20:00.000Z"
);
export const demoCancelledWatch = watch(
"run-start",
{
kind: "run_start",
runId: DEMO_WORLD.waitingRunId,
note: "Tell me when this run starts.",
maxHours: 1,
checkEveryMinutes: 1,
},
"run-start",
"cancelled",
"2026-07-27T10:01:00.000Z",
"2026-07-27T11:01:00.000Z"
);
export const demoWatchRow: DemoWatch[] = [
demoRunFinishedWatch,
demoBacklogDrainWatch,
demoErrorRecurrenceWatch,
demoHealthRecoveryWatch,
demoCancelledWatch,
];
export const demoActiveWatchRow: DemoWatch[] = [demoRunFinishedWatch, demoBacklogDrainWatch];
export const demoWatchNarration = {
wake: `**The retry finished.** \`${DEMO_WORLD.failedRunId}\` completed successfully 4 minutes ago, on attempt 2 — the provider accepted the request once the delay pushed it out of the rate-limit window.
I've stopped watching it. The other 40 runs from the same burst are still queued behind the concurrency limit; ask me if you want them watched too.`,
expiry: `**I've stopped watching \`${DEMO_WORLD.backlogQueue}\`.** The 6-hour window is up and the backlog never fully drained — it's down from 4,812 to 610 pending, so it's clearing, just slower than the window I was given.
Ask again if you want another 6 hours.`,
expiryUnverified: `**I've stopped watching prod's health, but I couldn't verify the condition at expiry.** The health data was unavailable on my last few checks, so I can't tell you whether prod recovered — only that I never saw it recover.
Re-run the health report to get a current answer.`,
cancelled: `Stopped watching \`${DEMO_WORLD.waitingRunId}\`.`,
} as const;
export const demoWatches = {
runFinished: demoRunFinishedWatch,
backlogDrain: demoBacklogDrainWatch,
errorRecurrence: demoErrorRecurrenceWatch,
healthRecovery: demoHealthRecoveryWatch,
cancelled: demoCancelledWatch,
row: demoWatchRow,
activeRow: demoActiveWatchRow,
narration: demoWatchNarration,
} as const;
@@ -1,10 +1,62 @@
// Every id the demo layer produces contains "demo".
// Every id the demo layer produces contains "demo". Resource ids carry the marker
// inline: `trigger://` segments are percent-encoded, so `demo:` would render as `demo%3A`.
import { formatTriggerUri, type TriggerUri } from "@internal/dashboard-agent-contracts";
export const DEMO_ID_PREFIX = "demo:";
export const DEMO_MARKER = "demo";
export function demoId(rest: string): string {
return `${DEMO_ID_PREFIX}${rest}`;
}
export const DEMO_PROJECT_REF = "proj_demo00000000000000";
export const DEMO_ENVIRONMENT_ID = "env_demo00000000000000";
const scope = { projectRef: DEMO_PROJECT_REF, environmentId: DEMO_ENVIRONMENT_ID };
export function demoRunsUri(): TriggerUri {
return formatTriggerUri({ kind: "runs", ...scope });
}
export function demoRunUri(runId: string): TriggerUri {
return formatTriggerUri({ kind: "run", ...scope, runId });
}
export function demoSpanUri(runId: string, spanId: string): TriggerUri {
return formatTriggerUri({ kind: "span", ...scope, runId, spanId });
}
export function demoErrorUri(fingerprint: string): TriggerUri {
return formatTriggerUri({ kind: "error", ...scope, fingerprint });
}
export function demoQueueUri(name: string): TriggerUri {
return formatTriggerUri({ kind: "queue", ...scope, name });
}
export function demoDeploymentUri(version: string): TriggerUri {
return formatTriggerUri({ kind: "deployment", ...scope, version });
}
export function demoReportUri(key: string): TriggerUri {
return formatTriggerUri({ kind: "report", ...scope, key });
}
export function demoSourceUri(sha: string, path: string, line?: number): TriggerUri {
return formatTriggerUri({
kind: "source",
...scope,
sha,
path,
...(line !== undefined ? { line } : {}),
});
}
export function demoInvestigationUri(investigationId: string): TriggerUri {
return formatTriggerUri({ kind: "investigation", ...scope, investigationId });
}
export const DEMO_WORLD = {
failedRunId: "run_demo0f2c91",
failedSpanId: "span_demoa41b",
@@ -0,0 +1,7 @@
// Must stay free of server imports. `demo.test.ts` asserts that.
export { DEMO_ID_PREFIX, DEMO_MARKER, DEMO_WORLD, demoId, demoReportUri, demoRunsUri } from "./ids";
export * as demoFixtures from "./fixtures";
export { DemoChartCard } from "./components/DemoChartCard";
export { DemoIntentBubble } from "./components/DemoIntentBubble";
@@ -1,5 +1,55 @@
import { describe, expect, it } from "vitest";
import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota";
import {
countUserMessages,
FREE_PLAN_MESSAGE_LIMIT,
MESSAGE_QUOTA_REACHED_ERROR,
messageQuotaReachedCopy,
parseQuotaReachedResponse,
quotaResponseUpdate,
resolveMessageLimit,
resolveMessageQuota,
shouldClearCapReached,
} from "./message-quota";
describe("quotaResponseUpdate", () => {
it("takes both fields from a coherent body", () => {
expect(quotaResponseUpdate({ used: 30, limit: 50 })).toEqual({ used: 30, limit: 50 });
expect(quotaResponseUpdate({ used: 30, limit: null })).toEqual({ used: 30, limit: null });
expect(quotaResponseUpdate({ used: 0, limit: 0 })).toEqual({ used: 0, limit: 0 });
});
it("changes nothing on a degraded body", () => {
// Control break: apply `{}` field-by-field and a good {used:30, limit:50} read decays to
// used 30 against the client's 20 — "reached" against a cap the server never set.
expect(quotaResponseUpdate({})).toBeNull();
expect(quotaResponseUpdate(null)).toBeNull();
expect(quotaResponseUpdate({ limit: 50 })).toBeNull();
});
});
describe("resolveMessageLimit", () => {
it("prefers a finite server-resolved limit over the client constant", () => {
expect(resolveMessageLimit(5)).toBe(5);
expect(resolveMessageLimit(0)).toBe(0);
expect(resolveMessageLimit(500)).toBe(500);
});
it("keeps the free-plan nudge when the server has no finite limit", () => {
// Pre-P0 the server limit is the unlimited sentinel and is sent as null: the client's
// own 20 IS the nudge. Control break: thread the server number here and it disappears.
expect(resolveMessageLimit(null)).toBe(FREE_PLAN_MESSAGE_LIMIT);
expect(resolveMessageLimit(undefined)).toBe(FREE_PLAN_MESSAGE_LIMIT);
});
it("caps against the server limit once it is known", () => {
expect(
resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(5) })
).toMatchObject({ kind: "reached", limit: 5 });
expect(
resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(null) })
).toMatchObject({ kind: "within", limit: FREE_PLAN_MESSAGE_LIMIT, remaining: 15 });
});
});
describe("resolveMessageQuota", () => {
it("caps a Free plan at the limit", () => {
@@ -42,6 +92,76 @@ describe("resolveMessageQuota", () => {
});
});
describe("parseQuotaReachedResponse", () => {
it("maps a create/in 403 cap body to the limit", () => {
// Both the create path and the `in` transport refuse with this exact body.
expect(
parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 })
).toEqual({ limit: 20, planResolved: true });
});
it("falls back to the free limit when the body omits it", () => {
expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({
limit: FREE_PLAN_MESSAGE_LIMIT,
planResolved: false,
});
});
it("ignores other errors and non-403 statuses so they surface normally", () => {
expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull();
expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull();
expect(parseQuotaReachedResponse(403, null)).toBeNull();
});
});
describe("shouldClearCapReached", () => {
const readQuota = (data: { used?: number; limit?: number | null } | null) => {
const update = quotaResponseUpdate(data);
return resolveMessageQuota({
isFreePlan: true,
used: update?.used,
limit: resolveMessageLimit(update?.limit),
});
};
it("releases the block once a read shows capacity", () => {
// Refused at 20/20, then the allowance resets or the plan's cap grows.
expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false);
expect(shouldClearCapReached(readQuota({ used: 0, limit: 20 }))).toBe(true);
expect(shouldClearCapReached(readQuota({ used: 20, limit: 500 }))).toBe(true);
});
it("keeps the block when the read is degraded, so the composer can't flash", () => {
expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false);
expect(shouldClearCapReached(readQuota(null))).toBe(false);
expect(shouldClearCapReached(readQuota({ limit: 20 }))).toBe(false);
});
it("keeps the block while the plan hasn't resolved", () => {
expect(shouldClearCapReached(resolveMessageQuota({ isFreePlan: undefined, used: 0 }))).toBe(
false
);
});
});
describe("messageQuotaReachedCopy", () => {
it("names the Free plan only for the client nudge", () => {
const copy = messageQuotaReachedCopy(FREE_PLAN_MESSAGE_LIMIT, false);
expect(copy).toContain(`all ${FREE_PLAN_MESSAGE_LIMIT} messages`);
expect(copy).toContain("Free plan");
// Control break: if the mapping leaked the server code, this fails.
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
});
it("stays plan-agnostic for a server-resolved limit, which paying orgs also hit", () => {
const copy = messageQuotaReachedCopy(500, true);
expect(copy).toContain("all 500 messages");
expect(copy).toContain("your plan");
expect(copy).not.toContain("Free plan");
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
});
});
describe("countUserMessages", () => {
it("counts only what the user sent", () => {
expect(
@@ -4,6 +4,27 @@ import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts";
// would reset.
export const FREE_PLAN_MESSAGE_LIMIT = 20;
/**
* The cap to show: the plan limit the server resolved, when it resolved a finite one. The
* server sends null while no plan limit exists (self-hosted, or before billing carries one),
* and then the free-plan nudge is the cap — dropping it would remove the nudge entirely.
*/
export function resolveMessageLimit(serverLimit: number | null | undefined): number {
return typeof serverLimit === "number" ? serverLimit : FREE_PLAN_MESSAGE_LIMIT;
}
/**
* What a `?quota=1` body should change, or null for a degraded one. Both fields move together:
* applying a `{}` on top of a good read would keep the count and drop back to the nudge limit,
* which reads as "reached" against a cap the server never set.
*/
export function quotaResponseUpdate(
data: { used?: number; limit?: number | null } | null | undefined
): { used: number; limit: number | null } | null {
if (typeof data?.used !== "number") return null;
return { used: data.used, limit: typeof data.limit === "number" ? data.limit : null };
}
export type MessageQuota =
| { kind: "unlimited" }
| { kind: "within"; used: number; limit: number; remaining: number }
@@ -27,6 +48,47 @@ export function resolveMessageQuota({
: { kind: "within", used, limit, remaining };
}
/**
* Whether a refusal-set cap can be released: only a read that proves capacity is back. An
* unknown quota (degraded read, plan not resolved) keeps the block, so the composer never
* flashes back for someone the server is about to refuse again.
*/
export function shouldClearCapReached(quota: MessageQuota): boolean {
return quota.kind === "within";
}
// The server code both the create and `in` paths refuse with. The client owns the copy,
// so this code must never reach the UI as text.
export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached";
// Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use
// this so a `message_quota_reached` code routes to the upgrade block, never a raw toast.
export function parseQuotaReachedResponse(
status: number,
data: { error?: string; limit?: number } | null | undefined
): { limit: number; planResolved: boolean } | null {
if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) {
return typeof data.limit === "number"
? { limit: data.limit, planResolved: true }
: { limit: FREE_PLAN_MESSAGE_LIMIT, planResolved: false };
}
return null;
}
/**
* The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw
* server code can never be what the user reads. Only the client's free-plan nudge may name
* the Free plan — a server-resolved cap also lands on paying orgs, whose allowance isn't it.
*/
export function messageQuotaReachedCopy(limit: number, planResolved: boolean): string {
return planResolved
? `You've used all ${limit} messages included in your plan this month. Your chats stay here to read.`
: `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
}
/** Why a suggestion chip is disabled: the upgrade block carries the full sentence. */
export const MESSAGE_QUOTA_REACHED_REASON = "You've used your message allowance";
// A watch's consent record is a user message the person never typed, so it is
// excluded here exactly as the stored count excludes it.
export function countUserMessages(messages: { role: string; id?: string }[]): number {
@@ -1,46 +1,65 @@
import type { UIMessage } from "@ai-sdk/react";
import { useEffect, useState } from "react";
import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota";
import { useEffect, useRef, useState } from "react";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import {
quotaResponseUpdate,
resolveMessageLimit,
resolveMessageQuota,
type MessageQuota,
} from "./message-quota";
// Always undefined until billing supplies plan detection, which means no cap.
// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired
// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free.
function useIsFreePlan(): boolean | undefined {
return undefined;
const subscription = useCurrentPlan()?.v3Subscription;
if (!subscription) return undefined;
return subscription.isPaying === false;
}
// Counted in two halves: the server aggregates other chats, this chat's own count
// comes from the live transcript so the message just sent counts immediately.
// `used` is the server's per-period count for the org. Re-read once a turn settles — the
// server increment happens mid-turn in the `.in` proxy, so reading on optimistic append
// would lag the count by one message and show the cap a message late.
export function useAgentMessageQuota({
actionPath,
chatId,
messages,
status,
}: {
actionPath: string;
chatId: string;
messages: UIMessage[];
status: string;
}): MessageQuota {
const isFreePlan = useIsFreePlan();
const [usedElsewhere, setUsedElsewhere] = useState<number | undefined>(undefined);
const [used, setUsed] = useState<number | undefined>(undefined);
const [serverLimit, setServerLimit] = useState<number | null>(null);
// Bumped each time the status leaves streaming/submitted, which drives the re-read.
const [settleTick, setSettleTick] = useState(0);
const prevStatus = useRef(status);
useEffect(() => {
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
const nowSettled = status === "ready" || status === "error";
prevStatus.current = status;
if (wasInFlight && nowSettled) setSettleTick((tick) => tick + 1);
}, [status]);
useEffect(() => {
if (isFreePlan !== true) return;
const controller = new AbortController();
void (async () => {
try {
const res = await fetch(`${actionPath}?quota=1&chatId=${encodeURIComponent(chatId)}`, {
signal: controller.signal,
});
const res = await fetch(`${actionPath}?quota=1`, { signal: controller.signal });
if (!res.ok) return;
const data = (await res.json()) as { used?: number };
if (typeof data.used === "number") setUsedElsewhere(data.used);
const update = quotaResponseUpdate(
(await res.json()) as { used?: number; limit?: number | null }
);
if (!update) return;
setUsed(update.used);
setServerLimit(update.limit);
} catch {
// Leave the count unknown, which means no cap. See `resolveMessageQuota`.
}
})();
return () => controller.abort();
}, [isFreePlan, actionPath, chatId]);
}, [isFreePlan, actionPath, chatId, settleTick]);
return resolveMessageQuota({
isFreePlan,
used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages),
});
return resolveMessageQuota({ isFreePlan, used, limit: resolveMessageLimit(serverLimit) });
}
+11 -1
View File
@@ -2,7 +2,11 @@ import { json } from "@remix-run/server-runtime";
import { QueryError } from "@internal/clickhouse";
import { z } from "zod";
import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import {
executeQuery,
isQueryConcurrencyRejection,
type QueryScope,
} from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
import { detectQueryTables } from "~/v3/detectQueryTables";
@@ -78,6 +82,12 @@ const { action, loader } = createActionApiRoute(
});
if (!queryResult.success) {
// A concurrency rejection is "too busy", not a bad query: 429 so callers retry it
// instead of rewriting a query that was fine.
if (isQueryConcurrencyRejection(queryResult.error)) {
return json({ error: queryResult.error.message }, { status: 429 });
}
// QueryError surfaces customer SQL problems (invalid syntax,
// unsupported construct). Returned to the caller as 400; system
// handles it gracefully, no alert needed.
@@ -7,6 +7,7 @@ import {
MESSAGE_TOO_LARGE_CODE,
MESSAGE_TOO_LARGE_ERROR,
} from "~/components/dashboard-agent/message-limits";
import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
@@ -15,6 +16,13 @@ import {
resolveDashboardAgentRepoSnapshot,
} from "~/services/dashboardAgent.server";
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server";
import {
agentTurnCountsAgainstQuota,
recordAgentMessageSent,
resolveAgentMessageQuota,
} from "~/services/dashboardAgentQuota.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { readBoundedBodyText } from "~/utils/boundedRequestBody.server";
@@ -115,6 +123,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
parsed = undefined;
}
// Hoisted so it is visible after the fetch: quota is charged only once the send succeeds.
let countsAgainstQuota = false;
if (parsed) {
// Actions are placed by the server only, and this proxy is the one path a browser
// can reach `.in` through.
@@ -127,6 +138,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
return tooLarge();
}
wellFormMessageText(parsed.payload.message?.parts);
// Only a real user message consumes quota; action turns were refused above.
countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
if (countsAgainstQuota) {
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
organizationId: project.organizationId,
});
if (quota?.reached) {
return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 });
}
}
let userActorToken: string;
try {
userActorToken = await mintDashboardAgentUserActorToken(user.id, {
@@ -165,6 +189,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
try {
const upstream = await fetch(upstreamUrl, { method: "POST", headers, body });
const text = await upstream.text();
// Charge quota only for a delivered message: a non-2xx upstream (or a throw below)
// must not burn a send that never reached the agent.
if (countsAgainstQuota && upstream.ok) {
await recordAgentMessageSent(dashboardAgentDb, {
organizationId: project.organizationId,
});
}
return new Response(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
@@ -3,7 +3,6 @@ import {
chatExists,
countUnreadWatchWakes,
countChatsWithUnreadWork,
countUserMessages,
createChat,
getChatMessages,
getSession,
@@ -28,6 +27,7 @@ import {
MESSAGE_TOO_LARGE_CODE,
MESSAGE_TOO_LARGE_ERROR,
} from "~/components/dashboard-agent/message-limits";
import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota";
import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
@@ -50,9 +50,15 @@ import {
startDashboardAgentSession,
} from "~/services/dashboardAgent.server";
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server";
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
import {
recordAgentMessageSent,
resolveAgentMessageQuota,
UNLIMITED_AGENT_MESSAGES,
} from "~/services/dashboardAgentQuota.server";
import { logger } from "~/services/logger.server";
import { resolveTriggerUri } from "~/services/resolveTriggerUri.server";
import { requireUser } from "~/services/session.server";
@@ -151,15 +157,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) return json({ error: "Project not found" }, { status: 404 });
// The open chat is excluded and counted from the live transcript instead, so an
// unpersisted turn still counts against the cap.
// The per-period counter, org-wide: a deleted chat can't lower it within the period.
if (searchParams.get("quota") === "1") {
const used = await countUserMessages(dashboardAgentDb, {
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
organizationId: project.organizationId,
userId,
excludeChatId: searchParams.get("chatId") ?? undefined,
});
return json({ used });
if (!quota) return json({});
// The sentinel is "no plan limit" — send null so the client keeps its own free-plan nudge
// instead of showing a number nobody would ever reach.
return json({
used: quota.used,
limit: quota.limit < UNLIMITED_AGENT_MESSAGES ? quota.limit : null,
});
}
const chatId = searchParams.get("chatId");
@@ -291,6 +300,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return messageTooLarge();
}
wellFormMessageText(firstMessage.parts);
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
organizationId: project.organizationId,
});
if (quota?.reached) {
return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 });
}
let clientData: Record<string, unknown> | undefined;
try {
clientData = parsed.data.clientData
@@ -388,6 +406,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
throw error;
}
// Only the head start dispatches the first message here; a cold start sends it through
// the `in` proxy, which counts it there. Counting both would double-count.
if (headStarted) {
await recordAgentMessageSent(dashboardAgentDb, {
organizationId: project.organizationId,
});
}
let publicAccessToken: string;
try {
publicAccessToken = await mintDashboardAgentToken(chatId);
@@ -0,0 +1,87 @@
import {
INVESTIGATION_CAPABILITIES_VERSION,
type InvestigationCapabilities,
} from "@internal/dashboard-agent-contracts";
import { demoFixtures } from "~/components/dashboard-agent/demo";
import { InvestigationCard } from "~/components/dashboard-agent/InvestigationCard";
import { investigationBlock } from "../storybook.agent-ui/fixtures";
import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery";
const { demoInvestigations } = demoFixtures;
const citedUri = (
fixture: (typeof demoInvestigations)[keyof typeof demoInvestigations],
kind: string
) => fixture.evidence.find((evidence) => evidence.kind === kind)!.uri;
const codeGroundedCapabilities: InvestigationCapabilities = {
version: INVESTIGATION_CAPABILITIES_VERSION,
actions: [
{
kind: "show_code",
label: "Show code",
intent: {
kind: "ask",
prompt:
"Show me the code behind this and propose the minimal fix as a fenced diff, anchored to the file, line and commit you read.",
},
},
{
kind: "view_similar",
label: "View similar failures",
intent: { kind: "navigate", target: citedUri(demoInvestigations.concluded, "error") },
},
],
};
const notCodeGroundedCapabilities: InvestigationCapabilities = {
version: INVESTIGATION_CAPABILITIES_VERSION,
actions: [
{
kind: "view_similar",
label: "View the queue",
intent: { kind: "navigate", target: citedUri(demoInvestigations.concludedNoCode, "queue") },
},
],
};
const STATES: Record<string, React.ReactNode> = {
"investigation-card-streaming-rev1": (
<InvestigationCard block={investigationBlock(demoInvestigations.streamingRev1)} />
),
"investigation-card-concluded": (
<InvestigationCard block={investigationBlock(demoInvestigations.concluded)} />
),
"investigation-card-concluded-code-grounded": (
<InvestigationCard
block={investigationBlock(demoInvestigations.concluded, codeGroundedCapabilities)}
resolveUri={fixtureResolveUri}
onIntent={noop}
/>
),
"investigation-card-concluded-not-code-grounded": (
<InvestigationCard
block={investigationBlock(demoInvestigations.concludedNoCode, notCodeGroundedCapabilities)}
resolveUri={fixtureResolveUri}
onIntent={noop}
/>
),
"investigation-card-inconclusive": (
<InvestigationCard
block={investigationBlock(demoInvestigations.inconclusive)}
defaultExpanded
resolveUri={fixtureResolveUri}
/>
),
"investigation-card-degraded": (
<InvestigationCard
block={investigationBlock(demoInvestigations.degraded)}
defaultExpanded
resolveUri={fixtureResolveUri}
/>
),
};
export default function Story() {
return <GalleryPage page="investigation" states={STATES} />;
}
@@ -0,0 +1,32 @@
import { DEMO_WORLD, demoFixtures, demoReportUri } from "~/components/dashboard-agent/demo";
import { ReportView } from "~/components/dashboard-agent/ReportView";
import { untrustworthyReport } from "../storybook.agent-ui/fixtures";
import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery";
const reportUri = demoReportUri(DEMO_WORLD.reportKey);
const STATES: Record<string, React.ReactNode> = {
"report-view-healthy": (
<ReportView
vm={demoFixtures.demoHealthyReport}
reportUri={reportUri}
onIntent={noop}
resolveUri={fixtureResolveUri}
/>
),
"report-view-degraded": (
<ReportView
vm={demoFixtures.demoDegradedReport}
reportUri={reportUri}
onIntent={noop}
resolveUri={fixtureResolveUri}
/>
),
"report-view-untrustworthy": (
<ReportView vm={untrustworthyReport} onIntent={noop} resolveUri={fixtureResolveUri} />
),
};
export default function Story() {
return <GalleryPage page="report" states={STATES} />;
}
@@ -0,0 +1,174 @@
import {
safeParseStoredViewBlock,
viewBlockSchema,
watchExternalNotificationLine,
} from "@internal/dashboard-agent-contracts";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DEMO_MARKER } from "~/components/dashboard-agent/demo/ids";
import { planDiagnosisActions } from "~/components/dashboard-agent/diagnosis-actions";
import { renderableActions } from "~/components/dashboard-agent/view-actions";
import { reportTrust } from "~/presenters/v3/reports/report-layout";
import {
externalServiceDiagnosis,
fullDiagnosis,
lowConfidenceDiagnosis,
offerActionsBlock,
revisedDiagnosisBlocks,
untrustworthyReport,
watchConfirmationBlock,
watchDegradedConfirmationBlock,
watchSatisfiedBlock,
} from "./fixtures";
/**
* The gallery's hand-written fixtures, checked against the code that reads them rather
* than against themselves. A fixture that still typechecks but no longer matches what
* the product emits would otherwise render a state nobody can reach.
*/
describe("gallery view blocks", () => {
it("parses every enveloped block through the schema the product persists with", () => {
for (const block of [
...revisedDiagnosisBlocks,
offerActionsBlock,
watchConfirmationBlock,
watchDegradedConfirmationBlock,
watchSatisfiedBlock,
]) {
const result = viewBlockSchema.safeParse(block);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
}
});
it("parses the envelope-less diagnoses through the stored-block schema", () => {
for (const block of [fullDiagnosis, externalServiceDiagnosis, lowConfidenceDiagnosis]) {
const result = safeParseStoredViewBlock(block);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
}
});
/** Every stored spec is normalized, so an unprefixed fingerprint is a shape no card sees. */
it("cites error fingerprints in the normalized form the watch service stores", () => {
for (const action of offerActionsBlock.actions) {
if (action.intent.kind !== "watch" || action.intent.spec.kind !== "error_recurrence")
continue;
const { fingerprint } = action.intent.spec;
expect(fingerprint).toBe(ErrorId.toId(fingerprint));
}
});
it("offers only actions the panel would render", () => {
expect(renderableActions(offerActionsBlock.actions)).toHaveLength(
offerActionsBlock.actions.length
);
});
/**
* The gallery renders outside a project route, so the card resolves no run path and drops
* the button. With a resolver both survive: the fixture's actions are still ones the card
* can plan, not ones it silently discards.
*/
it("plans every diagnosis action once its destination resolves", () => {
const planned = planDiagnosisActions(fullDiagnosis.actions ?? [], {
runPath: (runId) => `/runs/${runId}`,
docsUrl: (target) => target,
});
expect(planned.map((action) => action.kind)).toEqual(["view_run", "docs"]);
});
});
/**
* `demo.test.ts` marks the demo layer's ids. These fixtures are hand-written next to the
* route, so the same rule is asserted here: a `view_run` or `navigate` the presenter can
* resolve must land on demo data, never on somebody's environment.
*/
describe("gallery identifiers", () => {
// The digit keeps discriminants like `error_recurrence` out; ids always carry one.
const IDENTIFIER = /^(run|error|watch|queue|proj|env|deployment)_[a-z0-9]*\d|^trigger:\/\//i;
function strings(value: unknown, path = "fixture"): Array<[string, string]> {
if (typeof value === "string") return [[value, path]];
if (Array.isArray(value)) return value.flatMap((item, i) => strings(item, `${path}[${i}]`));
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, item]) => strings(item, `${path}.${key}`));
}
return [];
}
const fixtures = {
fullDiagnosis,
externalServiceDiagnosis,
lowConfidenceDiagnosis,
revisedDiagnosisBlocks,
offerActionsBlock,
watchConfirmationBlock,
watchDegradedConfirmationBlock,
watchSatisfiedBlock,
untrustworthyReport,
};
it("names no resource that isn't demo data", () => {
for (const [value, path] of strings(fixtures)) {
if (!IDENTIFIER.test(value)) continue;
expect(value, path).toContain(DEMO_MARKER);
}
});
it("watches only demo subjects, whatever shape their id takes", () => {
for (const action of offerActionsBlock.actions) {
if (action.intent.kind !== "watch") continue;
const subject = Object.entries(action.intent.spec).filter(([key]) =>
["queue", "runId", "fingerprint"].includes(key)
);
expect(subject.length).toBeGreaterThan(0);
for (const [key, value] of subject) expect(String(value), key).toContain(DEMO_MARKER);
}
});
});
describe("gallery watch confirmations", () => {
it("states the external outcome each confirmation claims", () => {
expect(watchConfirmationBlock.followUp).toContain(
watchExternalNotificationLine({ status: "enabled" })
);
expect(watchDegradedConfirmationBlock.followUp).toContain(
watchExternalNotificationLine({
status: "unavailable",
reason: "email_alerts_not_configured",
})
);
});
it("says the first check could not run on the degraded one, and not on the other", () => {
expect(watchDegradedConfirmationBlock.detail).toBeTruthy();
expect(watchConfirmationBlock.detail).toBeNull();
});
});
describe("gallery report", () => {
it("names an untrustworthy reason the card has a caveat for", () => {
const trust = reportTrust(untrustworthyReport);
expect(trust?.badge).toBe("stale data");
});
});
/**
* `demo/index.ts` re-exports `DemoChartCard` and `DemoIntentBubble`, so importing it here would
* pull React components into a suite that runs without a DOM. `demo.test.ts` reaches past the
* barrel for the same reason; these fixtures and their test do too.
*
* Structural: what a module drags in is not observable from inside it.
*/
describe("the gallery fixtures stay out of the demo barrel", () => {
const BARREL = /from "~\/components\/dashboard-agent\/demo"/;
for (const file of ["fixtures.ts", "fixtures.test.ts"]) {
it(`${file} reaches past it`, () => {
const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
expect(BARREL.test(source), `${file} imports the demo barrel`).toBe(false);
expect(source).toContain('"~/components/dashboard-agent/demo/');
});
}
});
@@ -0,0 +1,410 @@
import type { UIMessage } from "@ai-sdk/react";
import {
VIEW_BLOCK_VERSION,
type DiagnosisBlock,
type InvestigationBlock,
type InvestigationCapabilities,
type ReportViewModelPayload,
type ViewBlock,
type WatchResultBlock as WatchResultBlockPayload,
} from "@internal/dashboard-agent-contracts";
import * as demoFixtures from "~/components/dashboard-agent/demo/fixtures";
import { DEMO_WORLD, demoId, demoRunsUri } from "~/components/dashboard-agent/demo/ids";
import type { TurnActivity } from "~/components/dashboard-agent/DashboardAgentMessages";
import { watchConfirmationBlockBody, watchOneShotBlockBody } from "~/presenters/v3/dashboardAgent";
import {
queueWatchRecommendation,
runWatchRecommendation,
} from "~/components/dashboard-agent/watch-recommendations";
export function investigationBlock(
fixture: (typeof demoFixtures.demoInvestigations)[keyof typeof demoFixtures.demoInvestigations],
capabilities?: InvestigationCapabilities
): InvestigationBlock {
const { investigationId, revision, ...investigation } = fixture;
return {
type: "investigation",
id: investigationId,
revision,
version: VIEW_BLOCK_VERSION,
investigation,
...(capabilities ? { capabilities } : {}),
};
}
const {
assistantMessage,
demoDiagnosisBlockFirstPass,
demoDiagnosisBlockRevised,
demoLegacyDiagnosisBlock,
failedToolPart,
pendingToolPart,
reasoningPart,
renderViewPart,
sourceUrlPart,
streamingTextPart,
textPart,
toolPart,
userMessage,
} = demoFixtures;
const DEMO_WATCH_ID = demoId("watch_gallery");
// A stored error_recurrence spec holds the internal id, so the demo one drops its prefix.
const DEMO_FINGERPRINT = DEMO_WORLD.errorFingerprint.replace(/^error_/, "");
/** One transcript the message gallery renders, with the turn state it belongs to. */
export type DemoTranscript = {
messages: UIMessage[];
activity?: TurnActivity;
/** The turn's failure. Only the section that asks for it renders one. */
error?: string;
};
export const demoTranscripts = {
streamingText: {
activity: "working",
messages: [
userMessage("stream-q", "What's failing right now?"),
assistantMessage("stream-a", [
toolPart("query_runs", { period: "1h" }, { failures: 41 }, "query-runs-streaming"),
streamingTextPart(
"41 runs failed in the last hour, and they're all `send-order-receipt`. The error is the same every time — a 429 from the email provider, which means"
),
]),
],
},
reasoning: {
activity: "working",
messages: [
userMessage("inv-q", "Why did this run fail?"),
assistantMessage("inv-step1", [
reasoningPart(
"Start from the run itself: status, attempts, and which span failed. Don't guess at a cause before reading the error."
),
toolPart(
"get_run_details",
{ runId: DEMO_WORLD.failedRunId },
{
runId: DEMO_WORLD.failedRunId,
status: "COMPLETED_WITH_ERROR",
attempts: 3,
error: "ProviderError: 429 Too Many Requests",
},
"get-run-details"
),
textPart(
`\`${DEMO_WORLD.failedRunId}\` failed three times in 19 seconds, every attempt with the same error from the email provider. Three things could produce that, so I'll test them one at a time rather than settle on the first plausible one.`
),
]),
],
},
toolInFlight: {
activity: "working",
messages: [
userMessage("tool-q", "Check the queue depth for me."),
assistantMessage("tool-intro", [
textPart(
`Counting what's pending across the environment first, then pulling \`${DEMO_WORLD.queue}\` on its own so we can see whether the depth is one queue or all of them.`
),
]),
assistantMessage("tool-a", [
toolPart(
"run_query",
{ query: "SELECT count() FROM task_runs WHERE status = 'PENDING'" },
{ rows: [{ "count()": 4812 }] },
"run-query-done"
),
pendingToolPart(
"get_queue",
{ queue: DEMO_WORLD.queue, period: "1h" },
"get-queue-pending"
),
]),
],
},
errorRetry: {
error: "The chat stopped unexpectedly. Nothing was saved for this turn.",
messages: [
userMessage("err-q", "Chart failures by task for the last week."),
assistantMessage("err-a", [
failedToolPart(
"run_query",
{ query: "SELECT task_identifier, count() FROM task_runs", period: "7d" },
"query timed out after 30s",
"run-query-failed"
),
]),
],
},
// Cut before the follow-up turn: this section is about the one `render_view` part,
// which carries two revisions of a diagnosis plus an envelope-less legacy block.
renderView: {
messages: [
userMessage("res-q", "Did this happen last month too?"),
assistantMessage("res-a", [
textPart(
`Yes — same error, same task, three weeks ago. \`${DEMO_WORLD.taskId}\` hit the same rate limit on 6 July and it was diagnosed then too; the card below is that diagnosis, replayed from this conversation rather than re-run. The retry config hasn't changed since, which is why it came back.`
),
renderViewPart(
[demoDiagnosisBlockFirstPass, demoDiagnosisBlockRevised, demoLegacyDiagnosisBlock],
"render-view-resumed"
),
]),
],
},
docsSources: {
messages: [
userMessage("docs-q", "How do retries actually work? Is the delay exponential?"),
assistantMessage("docs-a", [
toolPart(
"search_docs",
{ query: "retry configuration exponential backoff" },
{ hits: 3 },
"search-docs"
),
textPart(
`Yes — retries back off exponentially by default.
- \`maxAttempts\` counts the *first* attempt, so \`3\` means one try plus two retries.
- The delay is \`minTimeoutInMs * factor^(attempt - 1)\`, capped at \`maxTimeoutInMs\`.
- \`randomize: true\` adds jitter, which is what stops a whole batch retrying in lockstep — the thing that bit \`${DEMO_WORLD.taskId}\` above.`
),
sourceUrlPart("https://trigger.dev/docs/errors-retrying", "Errors & retrying"),
sourceUrlPart("https://trigger.dev/docs/tasks/overview", "Task options"),
]),
],
},
} satisfies Record<string, DemoTranscript>;
/* ------------------------------------------------------------------ *
* View blocks
* ------------------------------------------------------------------ */
export const fullDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: DEMO_WORLD.failedRunId,
summary:
"The run failed because processOrder threw on an order with no line items. The payload had an empty items array.",
category: "user_code_error",
likelyCause:
"processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.",
confidence: "high",
evidence: [
{
type: "error",
detail: "TypeError: Cannot read properties of undefined (reading 'sku')",
reference: DEMO_WORLD.failedRunId,
},
{ type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" },
{
type: "source",
detail: "The throwing line reads order.items[0].sku with no guard.",
reference: "src/trigger/processOrder.ts:18",
},
{
type: "historical_match",
detail: "14 runs of this task hit the same error in the last 24h.",
reference: DEMO_WORLD.errorFingerprint,
},
],
impact:
"14 runs of process-order failed with this error in the last 24 hours, all in production.",
nextSteps: [
"Guard against an empty items array at the top of processOrder and return early.",
"Validate the payload before triggering so empty orders never reach the task.",
],
actions: [
{ label: "View run", kind: "view_run", target: DEMO_WORLD.failedRunId },
{ label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" },
],
};
export const externalServiceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: DEMO_WORLD.slowRunId,
summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.",
category: "external_service",
likelyCause:
"The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.",
confidence: "medium",
evidence: [
{
type: "error",
detail: "TimeoutError: Stripe API timed out after 30s",
reference: DEMO_WORLD.slowRunId,
},
{ type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" },
],
impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.",
nextSteps: [
"Wrap the Stripe call in a retry with backoff.",
"Set an explicit request timeout shorter than the task's max duration.",
],
actions: [{ label: "View run", kind: "view_run", target: DEMO_WORLD.slowRunId }],
};
export const lowConfidenceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: DEMO_WORLD.priorRunId,
summary:
"The run crashed without a captured error, so the cause isn't conclusive from the available signals.",
category: "unknown",
likelyCause:
"The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.",
confidence: "low",
evidence: [
{ type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." },
{ type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." },
],
nextSteps: [
"Re-run with a larger machine to rule out out-of-memory.",
"Add logging around the last successful step to narrow where it stops.",
],
};
export const revisedDiagnosisBlocks: ViewBlock[] = [
{
...lowConfidenceDiagnosis,
id: `diagnosis-${DEMO_WORLD.failedRunId}`,
revision: 1,
version: VIEW_BLOCK_VERSION,
summary: "Revision 1 — first guess, before the logs came back. Should not render.",
},
{
...externalServiceDiagnosis,
id: `diagnosis-${DEMO_WORLD.failedRunId}`,
revision: 2,
version: VIEW_BLOCK_VERSION,
summary: "Revision 2 — narrowed to the payload, still unconfirmed. Should not render.",
},
{
...fullDiagnosis,
id: `diagnosis-${DEMO_WORLD.failedRunId}`,
revision: 3,
version: VIEW_BLOCK_VERSION,
summary:
"Revision 3 — the only card that should render: processOrder threw on an order with no line items.",
},
];
export const offerActionsBlock: ViewBlock = {
type: "actions",
id: "actions-offer",
revision: 0,
version: VIEW_BLOCK_VERSION,
actions: [
{
label: "Set up a watch",
intent: {
kind: "watch",
spec: {
kind: "error_recurrence",
fingerprint: DEMO_FINGERPRINT,
checkEveryMinutes: 15,
maxHours: 6,
note: "the TypeError in send-order-receipt",
},
},
},
{
label: "See its failed runs",
intent: { kind: "navigate", target: demoRunsUri() },
},
],
};
/* ------------------------------------------------------------------ *
* Watch result blocks
* ------------------------------------------------------------------ */
const WATCH_BLOCK_ENVELOPE = {
id: `watch:${DEMO_WATCH_ID}`,
revision: 0,
version: VIEW_BLOCK_VERSION,
} as const;
/** Both opt-ins took effect: the happy path a submit with `notifyExternally` produces. */
export const watchConfirmationBlock: WatchResultBlockPayload = {
...watchConfirmationBlockBody({
spec: queueWatchRecommendation(DEMO_WORLD.queue),
watchId: DEMO_WATCH_ID,
followUp: { investigateOnAttention: true, external: { status: "enabled" } },
}),
...WATCH_BLOCK_ENVELOPE,
};
/**
* The same submit, degraded: the creation-time check couldn't run and the email
* subscription failed. Neither fails the watch both are said out loud instead.
*/
export const watchDegradedConfirmationBlock: WatchResultBlockPayload = {
...watchConfirmationBlockBody({
spec: queueWatchRecommendation(DEMO_WORLD.queue),
watchId: DEMO_WATCH_ID,
unavailable: true,
followUp: {
investigateOnAttention: true,
external: { status: "unavailable", reason: "email_alerts_not_configured" },
},
}),
...WATCH_BLOCK_ENVELOPE,
};
export const watchSatisfiedBlock: WatchResultBlockPayload = {
...watchOneShotBlockBody({
spec: runWatchRecommendation(DEMO_WORLD.failedRunId),
result: "satisfied",
}),
...WATCH_BLOCK_ENVELOPE,
};
/* ------------------------------------------------------------------ *
* Reports
* ------------------------------------------------------------------ */
/** Every number is informational: the verdict stands but the card must say why. */
export const untrustworthyReport: ReportViewModelPayload = {
...demoFixtures.demoDegradedReport,
summary: {
severity: "crit",
statements: [
{ findingType: "flow", severity: "crit", reason: "unknown" },
{ findingType: "execution", severity: "crit", reason: "unknown" },
{ findingType: "liveness", severity: "crit" },
],
},
findings: demoFixtures.demoDegradedReport.findings.map((finding) =>
finding.type === "liveness"
? {
...finding,
severity: "crit",
reason: "stale",
recommendation: { code: "check_control_plane", link: "status" },
}
: {
...finding,
severity: "crit",
reason: "unknown",
recommendation: undefined,
attribution: undefined,
exclusions: undefined,
observations: undefined,
hedge: undefined,
anomalyWindow: undefined,
}
),
metrics: demoFixtures.demoDegradedReport.metrics.map((metric) =>
metric.id === "liveness"
? { ...metric, value: 21 * 60_000, severity: "crit" }
: { ...metric, annotation: undefined }
),
facts: { trustworthy: false, untrustworthyReason: "telemetry_stale" },
links: [{ key: "status", label: "status.trigger.dev", url: "https://status.trigger.dev" }],
footer: [{ code: "check_control_plane", link: "status" }],
};
@@ -0,0 +1,166 @@
import { Link } from "@remix-run/react";
import { safeParseTriggerUri } from "@internal/dashboard-agent-contracts";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
import {
GALLERY_PAGES,
groupsOnPage,
sectionsInGroup,
sectionsOnPage,
type GalleryPageId,
type GallerySection,
} from "./manifest";
export const noop = () => undefined;
export const PANEL = "w-[380px]";
const CANVAS = "bg-background-bright";
export const PANEL_FRAME = "rounded-lg border border-border-bright bg-background-bright";
export function Missing({ what }: { what: string }) {
return (
<div className="rounded-md border border-error/50 bg-error/10 px-3 py-2 text-xs text-error">
No renderer for {what}. The manifest and the gallery are out of sync.
</div>
);
}
export function fixtureResolveUri(uri: string): { label: string; url: string } | null {
const parsed = safeParseTriggerUri(uri);
if (!parsed.success) return null;
return { label: uri.split("/").slice(-1)[0]!, url: "#resolved-by-the-host" };
}
const WIDE_SECTIONS = new Set(["diagnosis-badge-matrix", "hero-fullscreen"]);
function Section({
section,
states,
}: {
section: GallerySection;
states: Record<string, React.ReactNode>;
}) {
const state = states[section.sectionId];
return (
<section id={section.sectionId} className="w-fit scroll-mt-4 space-y-1.5">
<h3 className="text-sm font-medium text-text-bright">{section.title}</h3>
<div className={cn(WIDE_SECTIONS.has(section.sectionId) ? "w-auto" : PANEL)}>
{state ?? <Missing what={`section "${section.sectionId}"`} />}
</div>
</section>
);
}
function ThemeToggle() {
return (
<div className="flex items-center gap-1.5">
{/* classic is still the default theme for most users, so it's in the pack */}
{(["classic", "dark", "light"] as const).map((theme) => (
<button
key={theme}
type="button"
onClick={() => document.documentElement.setAttribute("data-theme", theme)}
className="rounded border border-border-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition hover:text-text-bright"
>
{theme}
</button>
))}
</div>
);
}
function PageLinks({ page }: { page: GalleryPageId }) {
return (
<div className="flex flex-wrap gap-1.5">
{GALLERY_PAGES.map((entry) => (
<Link
key={entry.id}
to={`/storybook/${entry.slug}`}
className={cn(
"rounded border px-2 py-1 text-xs transition",
entry.id === page
? "border-border-bright bg-tertiary text-text-bright"
: "border-border-bright bg-background-bright text-text-dimmed hover:text-text-bright"
)}
>
{entry.title}
</Link>
))}
</div>
);
}
function Nav({ page }: { page: GalleryPageId }) {
return (
<nav className="sticky top-0 space-y-3 self-start py-6 pr-4">
{groupsOnPage(page).map(({ group, label }) => (
<div key={group} className="space-y-1">
<p className="text-[10px] uppercase tracking-wide text-text-faint">{label}</p>
<ul className="space-y-0.5">
{sectionsInGroup(group).map((section) => (
<li key={section.sectionId}>
<a
href={`#${section.sectionId}`}
className="block truncate text-xs text-text-dimmed transition hover:text-text-bright"
>
{section.title}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
);
}
export function GalleryPage({
page,
states,
}: {
page: GalleryPageId;
states: Record<string, React.ReactNode>;
}) {
const meta = GALLERY_PAGES.find((entry) => entry.id === page)!;
const sections = sectionsOnPage(page);
return (
<div className={cn("grid min-h-full grid-cols-[15rem_1fr] gap-4 px-6", CANVAS)}>
<Nav page={page} />
<div className="flex flex-col gap-10 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-4">
<Header1>Trigger Agent {meta.title}</Header1>
<ThemeToggle />
</div>
<PageLinks page={page} />
<Paragraph variant="small">
{meta.blurb} {sections.length} states, rendered in isolation at panel width (380px) from
the demo fixtures in{" "}
<code className="font-mono text-xs">app/components/dashboard-agent/demo/fixtures</code>.
The list lives in <code className="font-mono text-xs">manifest.ts</code>.
</Paragraph>
<Paragraph variant="extra-small">
Run ids, queues, errors and reports are fabricated. Deep links resolve inside a project,
so here they render as plain text or navigate nowhere. The theme buttons flip{" "}
<code className="font-mono text-xs">data-theme</code> on the root element.
</Paragraph>
</div>
{groupsOnPage(page).map(({ group, label }) => (
<div key={group} className="flex flex-col gap-4">
<Header2 className="border-b border-grid-bright pb-1">{label}</Header2>
<div className="flex flex-wrap items-start gap-8">
{sectionsInGroup(group).map((section) => (
<Section key={section.sectionId} section={section} states={states} />
))}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,290 @@
// Keep this file free of imports and JSX so plain node can read it.
export type GalleryPageId = "chat" | "view-blocks" | "report" | "investigation" | "watch";
export type GalleryPage = {
id: GalleryPageId;
slug: string;
title: string;
blurb: string;
};
export const GALLERY_PAGES: GalleryPage[] = [
{
id: "chat",
slug: "agent-ui",
title: "Chat UI",
blurb:
"The chat chrome: the blank-state hero, suggested prompts, the transcript and its one progress line, wake banners, watch chips and the context banner.",
},
{
id: "view-blocks",
slug: "agent-view-blocks",
title: "View blocks",
blurb:
"The envelope rules every card obeys, the diagnosis card, the actions block and the chart card.",
},
{
id: "report",
slug: "agent-report",
title: "Report view",
blurb: "The health report, one state per verdict it can reach.",
},
{
id: "investigation",
slug: "agent-investigation",
title: "Investigation card",
blurb: "One card per ending an investigation can have, plus the state while it is still going.",
},
{
id: "watch",
slug: "agent-watch",
title: "Watch card",
blurb:
"The configuration card, what a submitted card leaves in the transcript, and the wake headline.",
},
];
export type GalleryGroup =
| "card"
| "diagnosis"
| "view-blocks"
| "investigation"
| "report"
| "chart"
| "watches"
| "watch-card"
| "wakes"
| "hero"
| "prompts"
| "intents"
| "messages"
| "banner";
export type GallerySection = {
/** DOM id and deep-link anchor. Renaming breaks the link. */
sectionId: string;
title: string;
group: GalleryGroup;
};
export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [
{ group: "hero", page: "chat", label: "Blank-state hero" },
{ group: "prompts", page: "chat", label: "Suggested prompts" },
{ group: "messages", page: "chat", label: "Message-level states" },
{ group: "intents", page: "chat", label: "Intent bubbles" },
{ group: "wakes", page: "chat", label: "Wake banners" },
{ group: "watches", page: "chat", label: "Watch chips" },
{ group: "banner", page: "chat", label: "Context banner" },
{ group: "view-blocks", page: "view-blocks", label: "Envelope & actions" },
{ group: "card", page: "view-blocks", label: "Card chrome" },
{ group: "diagnosis", page: "view-blocks", label: "Diagnosis card" },
{ group: "chart", page: "view-blocks", label: "Chart card" },
{ group: "report", page: "report", label: "Report view" },
{ group: "investigation", page: "investigation", label: "Investigation card" },
{ group: "watch-card", page: "watch", label: "Watch card" },
];
export const MANIFEST: GallerySection[] = [
{ sectionId: "hero-panel", title: "Side panel (380px) — no page context", group: "hero" },
{
sectionId: "hero-panel-contextual",
title: "Side panel — failed run on the page",
group: "hero",
},
{ sectionId: "hero-fullscreen", title: "Fullscreen takeover — centred column", group: "hero" },
{ sectionId: "hero-in-chat", title: "Empty chat — hero without its own composer", group: "hero" },
{ sectionId: "prompts-default", title: "Default set, no page context", group: "prompts" },
{
sectionId: "prompts-contextual-fresh-failure",
title: "Contextual — fresh failure first",
group: "prompts",
},
{ sectionId: "prompts-promoted", title: "Promoted chip on top", group: "prompts" },
{ sectionId: "prompts-dismissed", title: "After a dismissal", group: "prompts" },
{
sectionId: "messages-streaming-text",
title: "Text part still streaming, with activity row",
group: "messages",
},
{ sectionId: "messages-reasoning", title: "Reasoning part", group: "messages" },
{
sectionId: "messages-tool-in-flight",
title: "Tool call in flight — the turn's one progress line",
group: "messages",
},
{
sectionId: "messages-tool-pending-pills",
title: "Progress labels — one per tool, including a card tool",
group: "messages",
},
{
sectionId: "messages-error-retry",
title: "Failed turn — error row and retry",
group: "messages",
},
{
sectionId: "messages-render-view",
title: "render_view part — blocks as cards",
group: "messages",
},
{
sectionId: "messages-investigation-live",
title: "Live investigation — the card, and the turn's one progress line under it",
group: "messages",
},
{ sectionId: "messages-docs-sources", title: "Answer with source links", group: "messages" },
{
sectionId: "intent-navigate-filtered-runs",
title: "Navigate — runs with filters",
group: "intents",
},
{ sectionId: "intent-watch", title: "Watch started", group: "intents" },
{
sectionId: "intent-rejected-propose-fix",
title: "Rejected — propose_fix is reserved",
group: "intents",
},
{ sectionId: "wake-positive", title: "Positive", group: "wakes" },
{ sectionId: "wake-attention", title: "Attention", group: "wakes" },
{ sectionId: "wake-neutral-impossible", title: "Neutral — no longer possible", group: "wakes" },
{ sectionId: "wake-unverified", title: "Unverified at the window's end", group: "wakes" },
{ sectionId: "watches-live", title: "All four states, cancellable", group: "watches" },
{ sectionId: "banner-prod", title: "Production environment", group: "banner" },
{ sectionId: "banner-preview-long", title: "Preview branch with a long name", group: "banner" },
{
sectionId: "view-blocks-revisions",
title: "Three same-id revisions collapse to one card",
group: "view-blocks",
},
{
sectionId: "view-blocks-mixed",
title: "Enveloped revisions plus a legacy block with no envelope",
group: "view-blocks",
},
{
sectionId: "view-blocks-actions-offer",
title: "Actions block — the watch offer as buttons",
group: "view-blocks",
},
{ sectionId: "card-compact", title: "Header plus a compact body", group: "card" },
{ sectionId: "card-roomy", title: "Header plus a roomy body", group: "card" },
{ sectionId: "card-headerless", title: "No header — body only", group: "card" },
{ sectionId: "diagnosis-full-high", title: "Full card, high confidence", group: "diagnosis" },
{
sectionId: "diagnosis-low-minimal",
title: "Low confidence, minimal evidence",
group: "diagnosis",
},
{
sectionId: "diagnosis-badge-matrix",
title: "Badge matrix — every category x confidence",
group: "diagnosis",
},
{
sectionId: "chart-with-actions",
title: "Ranking chart with actions on the top item",
group: "chart",
},
{ sectionId: "chart-empty", title: "Empty — no data to display", group: "chart" },
{ sectionId: "report-view-healthy", title: "Healthy — nothing to do", group: "report" },
{
sectionId: "report-view-degraded",
title: "Degraded — env limit saturation, actions wired",
group: "report",
},
{
sectionId: "report-view-untrustworthy",
title: "Stale telemetry — verdict unknown, numbers informational",
group: "report",
},
{
sectionId: "investigation-card-streaming-rev1",
title: "In progress — one hypothesis settled",
group: "investigation",
},
{
sectionId: "investigation-card-concluded",
title: "Concluded, collapsed",
group: "investigation",
},
{
sectionId: "investigation-card-concluded-code-grounded",
title: "Concluded, code-grounded — source citation and Show code",
group: "investigation",
},
{
sectionId: "investigation-card-concluded-not-code-grounded",
title: "Concluded, not code-grounded — no source citation, no Show code",
group: "investigation",
},
{
sectionId: "investigation-card-inconclusive",
title: "Inconclusive — no fix, what to check next",
group: "investigation",
},
{
sectionId: "investigation-card-degraded",
title: "Inconclusive, degraded after a tool failure — names what it couldn't read",
group: "investigation",
},
{ sectionId: "watch-card-compact", title: "Compact — the recommendation", group: "watch-card" },
{ sectionId: "watch-card-expanded", title: "Expanded (Customize)", group: "watch-card" },
{ sectionId: "watch-card-validation-error", title: "Validation error", group: "watch-card" },
{ sectionId: "watch-card-pending", title: "Pending create", group: "watch-card" },
{
sectionId: "watch-card-queue-below",
title: "Customize — back below a threshold",
group: "watch-card",
},
{
sectionId: "watch-card-queue-stalled",
title: "Customize — stopped moving (no parameter)",
group: "watch-card",
},
{
sectionId: "watch-card-confirmation",
title: "Confirmation block — both opt-ins took effect",
group: "watch-card",
},
{
sectionId: "watch-card-confirmation-degraded",
title: "Confirmation block — first check and email both unavailable",
group: "watch-card",
},
{
sectionId: "watch-card-one-shot-satisfied",
title: "One-shot result — already true",
group: "watch-card",
},
{
sectionId: "watch-card-toast-headline",
title: "Wake toast headline (fact first)",
group: "watch-card",
},
];
export function groupsOnPage(page: GalleryPageId) {
return GALLERY_GROUPS.filter((entry) => entry.page === page);
}
export function sectionsInGroup(group: GalleryGroup): GallerySection[] {
return MANIFEST.filter((section) => section.group === group);
}
export function sectionsOnPage(page: GalleryPageId): GallerySection[] {
return groupsOnPage(page).flatMap((entry) => sectionsInGroup(entry.group));
}
@@ -1,124 +1,337 @@
import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent";
import { ViewBlocks } from "~/components/dashboard-agent/view-catalog";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { UIMessage } from "@ai-sdk/react";
import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useState } from "react";
import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo";
import { ChatProgress, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout";
import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer";
import { DashboardAgentContextBanner } from "~/components/dashboard-agent/DashboardAgentContextBanner";
import { DashboardAgentHero } from "~/components/dashboard-agent/DashboardAgentHero";
import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAgentMessages";
import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts";
import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout";
import { liveProgress } from "~/components/dashboard-agent/progress-line";
import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner";
import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips";
import { cn } from "~/utils/cn";
import { demoTranscripts, investigationBlock, type DemoTranscript } from "./fixtures";
import { fixtureResolveUri, GalleryPage, noop, PANEL_FRAME } from "./gallery";
// Storybook for the dashboard agent's view catalog — the blocks the agent emits
// via its render_view tool. Each example is a real block spec rendered through
// the same ViewBlocks registry the chat panel uses, at roughly panel width.
const { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures;
const fullDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_a1b2c3d4e5",
summary:
"The run failed because processOrder threw on an order with no line items. The payload had an empty items array.",
category: "user_code_error",
likelyCause:
"processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.",
confidence: "high",
evidence: [
{
type: "error",
detail: "TypeError: Cannot read properties of undefined (reading 'sku')",
reference: "run_a1b2c3d4e5",
},
{ type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" },
{
type: "source",
detail: "The throwing line reads order.items[0].sku with no guard.",
reference: "src/trigger/processOrder.ts:18",
},
{
type: "historical_match",
detail: "14 runs of this task hit the same error in the last 24h.",
reference: "error_emptyorder",
},
],
impact:
"14 runs of process-order failed with this error in the last 24 hours, all in production.",
nextSteps: [
"Guard against an empty items array at the top of processOrder and return early.",
"Validate the payload before triggering so empty orders never reach the task.",
],
actions: [
{ label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" },
{ label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" },
],
};
const externalServiceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_f6g7h8i9j0",
summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.",
category: "external_service",
likelyCause:
"The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.",
confidence: "medium",
evidence: [
{
type: "error",
detail: "TimeoutError: Stripe API timed out after 30s",
reference: "run_f6g7h8i9j0",
},
{ type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" },
],
impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.",
nextSteps: [
"Wrap the Stripe call in a retry with backoff.",
"Set an explicit request timeout shorter than the task's max duration.",
],
actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }],
};
const lowConfidenceDiagnosis: DiagnosisBlock = {
type: "diagnosis",
runId: "run_k1l2m3n4o5",
summary:
"The run crashed without a captured error, so the cause isn't conclusive from the available signals.",
category: "unknown",
likelyCause:
"The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.",
confidence: "low",
evidence: [
{ type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." },
{ type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." },
],
nextSteps: [
"Re-run with a larger machine to rule out out-of-memory.",
"Add logging around the last successful step to narrow where it stops.",
],
};
function Example({ title, block }: { title: string; block: ViewBlock }) {
function MessageHarness({
transcript,
withError = false,
}: {
transcript: DemoTranscript;
withError?: boolean;
}) {
return (
<div className="flex flex-col gap-2">
<Header2>{title}</Header2>
<div className="w-104 max-w-full">
<ViewBlocks blocks={[block]} />
<div className={PANEL_FRAME}>
<DashboardAgentMessages
messages={transcript.messages}
activity={transcript.activity ?? null}
error={withError && transcript.error ? new Error(transcript.error) : undefined}
onRetry={withError ? noop : undefined}
onDismissError={withError ? noop : undefined}
/>
</div>
);
}
const PENDING_PILL_TOOLS: { tool: string; input: unknown }[] = [
{ tool: "render_view", input: { blocks: [{ type: "diagnosis" }] } },
{ tool: "get_report", input: { window: "24h" } },
{ tool: "get_run", input: { runId: "run_demo" } },
{ tool: "run_query", input: { query: "SELECT count() FROM task_runs" } },
{ tool: "search_docs", input: { query: "concurrency limits" } },
{ tool: "brand_new_tool", input: {} },
];
function PendingPillsHarness() {
const lines = PENDING_PILL_TOOLS.map(({ tool, input }) => ({
tool,
progress: liveProgress(
[
demoFixtures.assistantMessage(`pending-${tool}`, [
demoFixtures.pendingToolPart(tool, input, `pending-${tool}`),
]),
],
"working"
),
}));
return (
<div className={PANEL_FRAME}>
<ChatTranscript>
{lines.map(({ tool, progress }) => (
<ChatTurn key={tool}>
<ChatProgress>{progress?.label}</ChatProgress>
</ChatTurn>
))}
</ChatTranscript>
</div>
);
}
function PromptsHarness({
context,
promoted,
dismissedIds = [],
}: {
context: AgentPageContext;
promoted?: SuggestedPrompt;
dismissedIds?: string[];
}) {
const signals =
context.signals.length > 0 ? context.signals.map((s) => s.kind).join(", ") : "no signals";
return (
<div className="space-y-1.5">
<p className="text-[10px] uppercase tracking-wide text-text-faint">
{context.page.kind} {signals}
</p>
<div className={cn(PANEL_FRAME, "py-4")}>
<DashboardAgentSuggestedPrompts
onSelect={noop}
pageContext={context}
promoted={promoted}
dismissedIds={dismissedIds}
/>
</div>
</div>
);
}
function HeroHarness({
context,
promoted,
fullscreen = false,
withComposer = true,
}: {
context: AgentPageContext;
promoted?: SuggestedPrompt;
fullscreen?: boolean;
withComposer?: boolean;
}) {
const [input, setInput] = useState("");
return (
<div className={cn(PANEL_FRAME, "flex h-[30rem] flex-col", fullscreen && "w-[60rem]")}>
<AgentPanelColumn fullscreen={fullscreen}>
<DashboardAgentHero
onSelect={noop}
pageContext={context}
promoted={promoted}
dismissedIds={[]}
composer={
withComposer ? (
<DashboardAgentComposer
layout="hero"
autoFocus={false}
value={input}
onChange={setInput}
onSubmit={noop}
onStop={noop}
isStreaming={false}
context={
<DashboardAgentContextBanner
projectSlug="demo-storefront"
environmentSlug="prod"
currentPage="Runs"
/>
}
/>
) : undefined
}
/>
</AgentPanelColumn>
</div>
);
}
function LiveInvestigationHarness() {
const messages: UIMessage[] = [
demoFixtures.userMessage("live-inv-q", "Why did this run fail?"),
demoFixtures.assistantMessage("live-inv", [
demoFixtures.renderViewPart(
[investigationBlock(demoInvestigations.streamingRev1)],
"render-live-investigation"
),
]),
];
return (
<div className={PANEL_FRAME}>
<DashboardAgentMessages
messages={messages}
activity="working"
resolveUri={fixtureResolveUri}
/>
</div>
);
}
const promotedPrompt: SuggestedPrompt = {
id: "sp:promo-storybook",
label: "Try the new health report",
prompt: "Give me a health report for this environment.",
source: "promoted",
};
// Resolver-minted ids: the panel resolves its own chips, so a demo-namespaced id would
// match nothing and the state would render undismissed.
const dismissedPromptIds = demoFixtures.demoResolvedDismissedPromptIds;
function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip {
return {
id: watch.id,
identity: watch.identity,
status: watch.status,
kind: watch.spec.kind,
note: watch.spec.note,
checkEveryMinutes: watch.spec.checkEveryMinutes,
expiresAt: watch.expiresAt,
};
}
function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage {
return {
id: `wake:watch:${watchId}:${outcome}`,
role: "assistant",
parts: [{ type: "text", text }],
};
}
const wakeWatches: WakeWatch[] = [
{
id: "watch_health",
kind: "health_recovery",
note: "prod health back to normal",
identity: "health_recovery:health",
resolution: "condition_met",
observedOutcome: { kind: "health_recovery", verified: true, severity: "ok" },
},
{
id: "watch_error",
kind: "error_recurrence",
note: "tell me if that TypeError comes back",
identity: "error_recurrence:a1b2c3d4e5f6",
resolution: "condition_met",
observedOutcome: { kind: "error_recurrence", verified: true, countSince: 6 },
},
{
id: "watch_queue_gone",
kind: "backlog_drain",
note: "tell me when the email-sends backlog clears",
identity: "backlog_drain:email-sends",
resolution: "condition_impossible",
observedOutcome: { kind: "backlog_drain", verified: true, depth: null },
},
{
id: "watch_unverified",
kind: "backlog_drain",
note: "tell me when the email-sends backlog clears",
identity: "backlog_drain:email-sends",
resolution: "window_completed",
observedOutcome: { kind: "backlog_drain", verified: false, depth: null },
},
];
function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) {
return (
<div className={PANEL_FRAME}>
<DashboardAgentMessages messages={[message]} activity={null} watches={watches} />
</div>
);
}
const STATES: Record<string, React.ReactNode> = {
"hero-panel": <HeroHarness context={demoPageContexts.other} />,
"hero-panel-contextual": <HeroHarness context={demoPageContexts.failedRun} />,
"hero-fullscreen": <HeroHarness context={demoPageContexts.failedRun} fullscreen />,
"hero-in-chat": <HeroHarness context={demoPageContexts.runs} withComposer={false} />,
"prompts-default": <PromptsHarness context={demoPageContexts.other} />,
"prompts-contextual-fresh-failure": <PromptsHarness context={demoPageContexts.failedRun} />,
"prompts-promoted": (
<PromptsHarness context={demoPageContexts.failedRun} promoted={promotedPrompt} />
),
"prompts-dismissed": (
<PromptsHarness context={demoPageContexts.failedRun} dismissedIds={dismissedPromptIds} />
),
"messages-streaming-text": <MessageHarness transcript={demoTranscripts.streamingText} />,
"messages-reasoning": <MessageHarness transcript={demoTranscripts.reasoning} />,
"messages-tool-in-flight": <MessageHarness transcript={demoTranscripts.toolInFlight} />,
"messages-tool-pending-pills": <PendingPillsHarness />,
"messages-error-retry": <MessageHarness transcript={demoTranscripts.errorRetry} withError />,
"messages-render-view": <MessageHarness transcript={demoTranscripts.renderView} />,
"messages-investigation-live": <LiveInvestigationHarness />,
"messages-docs-sources": <MessageHarness transcript={demoTranscripts.docsSources} />,
"intent-navigate-filtered-runs": (
<DemoIntentBubble intent={demoIntents.navigateToFailedRuns} onIntercept={noop} />
),
"intent-watch": <DemoIntentBubble intent={demoIntents.watch} onIntercept={noop} />,
"intent-rejected-propose-fix": (
<DemoIntentBubble intent={demoIntents.proposeFix} onIntercept={noop} />
),
"wake-positive": (
<WakeHarness
watches={wakeWatches}
message={wakeMessage(
"watch_health",
"fired",
"Production is back to normal: the failure rate has been under 1% for the last 15 minutes and the queue has drained. Nothing left for me to watch here."
)}
/>
),
"wake-attention": (
<WakeHarness
watches={wakeWatches}
message={wakeMessage(
"watch_error",
"fired",
"That TypeError is back — 6 runs of process-order failed with it in the last 10 minutes, all on version 20260620.2. Same empty-items payload as before."
)}
/>
),
"wake-neutral-impossible": (
<WakeHarness
watches={wakeWatches}
message={wakeMessage(
"watch_queue_gone",
"expired",
"The email-sends queue was deleted, so there is nothing left to drain. I've stopped watching."
)}
/>
),
"wake-unverified": (
<WakeHarness
watches={wakeWatches}
message={wakeMessage(
"watch_unverified",
"expired",
"The window ran out while I couldn't read the queue depth, so I can't tell you whether it drained. The last reading I do have was 42 pending, an hour ago."
)}
/>
),
"watches-live": <WatchChips watches={demoWatches.row.map(toWatchChip)} onCancel={noop} />,
"banner-prod": (
<DashboardAgentContextBanner
projectSlug="demo-storefront"
environmentSlug="prod"
currentPage="Runs"
/>
),
"banner-preview-long": (
<DashboardAgentContextBanner
projectSlug="demo-storefront"
environmentSlug="preview-demo-feature-rework-receipt-email-batching"
currentPage="Deployments"
/>
),
};
export default function Story() {
return (
<div className="flex flex-col gap-8 p-6">
<div className="flex flex-col gap-1">
<Header1>Dashboard agent UI</Header1>
<Paragraph variant="small">
Blocks the dashboard agent renders via its render_view tool, shown through the same
ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card,
shown here, and a chart block that runs a TRQL query live (only renders inside a
project/env, so it's not shown here). Run links resolve inside a project; here they render
as plain text.
</Paragraph>
</div>
<div className="flex flex-wrap gap-8">
<Example title="Diagnosis — full, high confidence" block={fullDiagnosis} />
<Example title="Diagnosis — external service, medium" block={externalServiceDiagnosis} />
<Example title="Diagnosis — low confidence, minimal" block={lowConfidenceDiagnosis} />
</div>
</div>
);
return <GalleryPage page="chat" states={STATES} />;
}
@@ -0,0 +1,124 @@
import type { DiagnosisBlock } from "@internal/dashboard-agent";
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
import { AGENT_CHART_PLOT_CLASS } from "~/components/dashboard-agent/AgentChart";
import { ConfidenceBadge } from "~/components/dashboard-agent/agent-badges";
import {
AgentCard,
AgentCardBody,
AgentCardHeader,
type AgentCardDensity,
} from "~/components/dashboard-agent/agent-card";
import { DemoChartCard, demoFixtures } from "~/components/dashboard-agent/demo";
import { RunDiagnosisCard } from "~/components/dashboard-agent/RunDiagnosisCard";
import { ViewBlocks } from "~/components/dashboard-agent/view-catalog";
import {
fullDiagnosis,
lowConfidenceDiagnosis,
offerActionsBlock,
revisedDiagnosisBlocks,
} from "../storybook.agent-ui/fixtures";
import { GalleryPage, noop } from "../storybook.agent-ui/gallery";
const DIAGNOSIS_CATEGORIES: DiagnosisBlock["category"][] = [
"user_code_error",
"configuration",
"dependency",
"timeout",
"out_of_memory",
"rate_limit",
"external_service",
"infrastructure",
"cancellation",
"unknown",
];
const CONFIDENCES: DiagnosisBlock["confidence"][] = ["high", "medium", "low"];
const badgeMatrixBlocks: DiagnosisBlock[] = DIAGNOSIS_CATEGORIES.map((category, i) => ({
...demoFixtures.demoDiagnosisBlockFirstPass,
category,
confidence: CONFIDENCES[i % CONFIDENCES.length]!,
evidence: [],
nextSteps: [],
actions: undefined,
impact: undefined,
}));
function EmptyChartCard() {
return (
<AgentCard>
<AgentCardHeader className="text-xs font-medium text-text-dimmed">
{demoFixtures.demoChart.title}
</AgentCardHeader>
<div className={AGENT_CHART_PLOT_CLASS}>
<QueryResultsChart
rows={[]}
columns={demoFixtures.demoChart.columns}
config={demoFixtures.demoChart.config}
timeRange={demoFixtures.demoChart.timeRange}
/>
</div>
</AgentCard>
);
}
/** The card primitive on its own: both body densities, and a card with no header. */
function CardChrome({ density, header }: { density?: AgentCardDensity; header?: boolean }) {
return (
<AgentCard>
{header ? (
<AgentCardHeader className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-dimmed">Card header</span>
<ConfidenceBadge confidence="high" />
</AgentCardHeader>
) : null}
<AgentCardBody density={density}>
<p className="text-sm text-text-bright">
The card owns its border, surface and insets; the transcript owns where it sits.
</p>
<p className="text-sm text-text-dimmed">
A second section, so the body's density is visible as the gap between them.
</p>
</AgentCardBody>
</AgentCard>
);
}
const STATES: Record<string, React.ReactNode> = {
"view-blocks-revisions": <ViewBlocks blocks={revisedDiagnosisBlocks} />,
"view-blocks-mixed": (
<ViewBlocks
blocks={[
demoFixtures.demoDiagnosisBlockFirstPass,
demoFixtures.demoDiagnosisBlockRevised,
demoFixtures.demoLegacyDiagnosisBlock,
]}
/>
),
"view-blocks-actions-offer": <ViewBlocks blocks={[offerActionsBlock]} onIntent={noop} />,
"card-compact": <CardChrome header density="compact" />,
"card-roomy": <CardChrome header density="roomy" />,
"card-headerless": <CardChrome />,
"diagnosis-full-high": <RunDiagnosisCard block={fullDiagnosis} />,
"diagnosis-low-minimal": <RunDiagnosisCard block={lowConfidenceDiagnosis} />,
"diagnosis-badge-matrix": (
<div className="flex flex-wrap gap-3">
{badgeMatrixBlocks.map((block, i) => (
<div key={i} className="w-[300px]">
<RunDiagnosisCard block={block} />
</div>
))}
</div>
),
"chart-with-actions": (
<DemoChartCard actions={demoFixtures.demoChartBlock.actions ?? []} onIntent={noop} />
),
"chart-empty": <EmptyChartCard />,
};
export default function Story() {
return <GalleryPage page="view-blocks" states={STATES} />;
}
@@ -0,0 +1,124 @@
import {
watchDraftFor,
withFollowUp,
withThreshold,
withVariant,
} from "~/components/dashboard-agent/watch-card";
import { WatchCard } from "~/components/dashboard-agent/WatchCard";
import {
errorWatchRecommendation,
queueWatchRecommendation,
runWatchRecommendation,
} from "~/components/dashboard-agent/watch-recommendations";
import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock";
import { watchWakeToastTitle, type WatchWake } from "~/components/dashboard-agent/WatchWakeToast";
import { cn } from "~/utils/cn";
import {
watchConfirmationBlock,
watchDegradedConfirmationBlock,
watchSatisfiedBlock,
} from "../storybook.agent-ui/fixtures";
import { GalleryPage, noop, PANEL_FRAME } from "../storybook.agent-ui/gallery";
const queueWatchDraft = watchDraftFor(queueWatchRecommendation("email-sends"));
const runWatchDraft = withFollowUp(watchDraftFor(runWatchRecommendation("run_a1b2c3d4e5")), {
investigateOnAttention: true,
});
const invalidThresholdDraft = withThreshold(
withVariant(queueWatchDraft, "queue_depth_above"),
Number.NaN
);
const queueBelowDraft = withThreshold(withVariant(queueWatchDraft, "queue_depth_below"), 100);
const queueStalledDraft = withVariant(queueWatchDraft, "queue_stalled");
const toastWakes: WatchWake[] = [
{
watchId: "watch_queue",
chatId: "chat_demo",
outcome: "fired",
note: "tell me when the email-sends backlog clears",
kind: "backlog_drain",
identity: "backlog_drain:email-sends",
resolution: "condition_met",
observedOutcome: { kind: "backlog_drain", verified: true, depth: 0 },
},
{
watchId: "watch_run_failed",
chatId: "chat_demo",
outcome: "fired",
note: "ping me when the nightly backfill finishes",
kind: "run_finished",
identity: "run_finished:run_a1b2c3d4e5",
resolution: "condition_met",
observedOutcome: {
kind: "run_finished",
verified: true,
finalStatus: "COMPLETED_WITH_ERRORS",
durationMs: 812_000,
},
},
];
function WakeToastHeadlines({ wakes }: { wakes: WatchWake[] }) {
return (
<div className={cn(PANEL_FRAME, "space-y-3 p-3")}>
{wakes.map((wake) => (
<div key={wake.watchId} className="space-y-0.5">
<p className="text-[10px] uppercase tracking-wide text-text-faint">{wake.kind}</p>
<p className="text-sm text-text-bright">{watchWakeToastTitle(wake)}</p>
<p className="text-xs text-text-dimmed">{wake.note}</p>
</div>
))}
</div>
);
}
const STATES: Record<string, React.ReactNode> = {
"watch-card-compact": (
<WatchCard draft={queueWatchDraft} onChange={noop} onSubmit={noop} onCancel={noop} />
),
"watch-card-expanded": (
<WatchCard
draft={runWatchDraft}
onChange={noop}
onSubmit={noop}
onCancel={noop}
defaultExpanded
/>
),
"watch-card-validation-error": (
<WatchCard
draft={invalidThresholdDraft}
onChange={noop}
onSubmit={noop}
onCancel={noop}
defaultExpanded
/>
),
"watch-card-pending": (
<WatchCard
draft={watchDraftFor(errorWatchRecommendation("a1b2c3d4e5f6"))}
onChange={noop}
onSubmit={noop}
onCancel={noop}
pending
/>
),
"watch-card-queue-below": (
<WatchCard draft={queueBelowDraft} onChange={noop} onSubmit={noop} defaultExpanded />
),
"watch-card-queue-stalled": (
<WatchCard draft={queueStalledDraft} onChange={noop} onSubmit={noop} defaultExpanded />
),
"watch-card-confirmation": <WatchResultBlock block={watchConfirmationBlock} />,
"watch-card-confirmation-degraded": <WatchResultBlock block={watchDegradedConfirmationBlock} />,
"watch-card-one-shot-satisfied": <WatchResultBlock block={watchSatisfiedBlock} />,
"watch-card-toast-headline": <WakeToastHeadlines wakes={toastWakes} />,
};
export default function Story() {
return <GalleryPage page="watch" states={STATES} />;
}
@@ -1,4 +1,4 @@
import { Toaster, toast } from "sonner";
import { toast } from "sonner";
import { Button } from "~/components/primitives/Buttons";
import { ToastUI } from "~/components/primitives/Toast";
@@ -17,6 +17,18 @@ export default function Story() {
message="This is a long error message that wraps over multiple lines so we can test the UI."
t="-"
/>
<ToastUI variant="agent" message="Agent info UI" t="-" />
<ToastUI
variant="agent"
title="Watch update"
message="Error error_c4b4a797 happened again — 1 new occurrence since the watch started."
t="-"
actionNode={
<Button variant="secondary/small" className="my-2 self-start">
Open chat
</Button>
}
/>
<br />
<Button
variant="primary/medium"
@@ -38,8 +50,33 @@ export default function Story() {
>
Trigger error toast
</Button>
<Toaster />
<Button
variant="secondary/medium"
onClick={() =>
toast.custom(
(t) => (
<ToastUI
variant="agent"
title="Watch update"
message="Error error_c4b4a797 happened again — 1 new occurrence since the watch started."
t={t as string}
actionNode={
<Button
variant="secondary/small"
className="my-2 self-start"
onClick={() => toast.dismiss(t as string)}
>
Open chat
</Button>
}
/>
),
{ duration: Infinity }
)
}
>
Trigger agent toast
</Button>
</div>
);
}
+22 -7
View File
@@ -7,10 +7,6 @@ import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
const stories: Story[] = [
{
name: "AI agent",
slug: "ai-agent",
},
{
name: "Animated panel",
slug: "animated-panel",
@@ -163,12 +159,31 @@ const stories: Story[] = [
name: "Usage",
slug: "usage",
},
// Dashboard agent section
{
sectionTitle: "Dashboard agent",
name: "Agent UI",
sectionTitle: "Trigger Agent",
name: "Chat UI",
slug: "agent-ui",
},
{
name: "View blocks",
slug: "agent-view-blocks",
},
{
name: "Report view",
slug: "agent-report",
},
{
name: "Investigation card",
slug: "agent-investigation",
},
{
name: "Watch card",
slug: "agent-watch",
},
{
name: "Icons & Buttons",
slug: "ai-agent",
},
// Forms section
{
sectionTitle: "Forms",
@@ -5,8 +5,11 @@
import {
listStaleOpenInvestigations,
recordInvestigationSweepAttempt,
settleInvestigationAndCloseCard,
settleInvestigationAsInconclusive,
type Investigation,
type SettledInvestigation,
type SettledInvestigationCard,
} from "@internal/dashboard-agent-db";
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
@@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000;
/** Per-run cap. Oldest first, so the rest land next run. */
const SWEEP_BATCH_LIMIT = 100;
/**
* After this many failed settle attempts a row is force-abandoned: settled `inconclusive`
* WITHOUT the closing card, so a card that never renders leaves the queue instead of
* looping forever. The rare stuck spinner is the price of not starving every other row.
*/
export const MAX_SWEEP_ATTEMPTS = 5;
export type InvestigationSweepResult = {
/** Stale `in_progress` rows seen. */
stale: number;
@@ -30,6 +40,8 @@ export type InvestigationSweepResult = {
closed: number;
/** A turn (or another sweep) settled it first. */
alreadySettled: number;
/** Rows past the attempt cap, force-settled without a card so they leave the queue. */
abandoned: number;
failed: number;
};
@@ -46,6 +58,10 @@ export type InvestigationSweepDeps = {
chatId: string;
note: string;
}) => Promise<SettledInvestigationCard | null>;
/** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */
recordAttempt?: (params: { id: string }) => Promise<number | null>;
/** Force a poison row terminal without the failing render path. */
forceAbandon?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
};
/**
@@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations(
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
const settleAndClose =
deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
const recordAttempt =
deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params));
const forceAbandon =
deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));
const result: InvestigationSweepResult = {
stale: 0,
settled: 0,
closed: 0,
alreadySettled: 0,
abandoned: 0,
failed: 0,
};
@@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations(
result.settled++;
if (outcome.closed) result.closed++;
} catch (error) {
// The settle rolled back, so the row is still `in_progress`. Record the attempt in
// its own write — this rotates the row to the back of the sweep order (see
// `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows.
let attempts: number | null = null;
try {
attempts = await recordAttempt({ id: investigation.id });
} catch (recordError) {
logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: recordError,
});
}
// Past the cap the card will never render; force it terminal without the render
// path so it leaves the queue instead of looping forever.
if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) {
try {
await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
result.abandoned++;
logger.warn(
"Dashboard agent investigation sweep: abandoned a card past the attempt cap",
{
investigationId: investigation.id,
chatId: investigation.chatId,
attempts,
}
);
continue;
} catch (abandonError) {
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: abandonError,
});
}
}
result.failed++;
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {
investigationId: investigation.id,
chatId: investigation.chatId,
attempts,
error,
});
}
@@ -0,0 +1,17 @@
declare global {
interface String {
toWellFormed(): string;
}
}
/** Postgres jsonb rejects a lone surrogate, so one in the text fails the persist every retry. */
export function wellFormMessageText(parts: unknown): void {
if (!Array.isArray(parts)) return;
for (const part of parts) {
const text = (part as { text?: unknown } | null)?.text;
if (typeof text === "string") {
(part as { text: string }).text = text.toWellFormed();
}
}
}
@@ -0,0 +1,104 @@
import type { Limits } from "@trigger.dev/platform";
import {
getAgentMessageUsage,
incrementAgentMessageUsage,
type DashboardAgentDb,
} from "@internal/dashboard-agent-db";
import { getCachedLimitAllowingZero } from "./platform.v3.server";
import { logger } from "./logger.server";
// The repo's unlimited sentinel. Never Infinity: it serializes to null in the limit cache.
export const UNLIMITED_AGENT_MESSAGES = 100_000_000;
// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted,
// so the fallback applies and the cap is effectively off.
const AGENT_MESSAGE_LIMIT_KEY = "agentMessages" as keyof Limits;
/** The billing period the counter is scoped to: a UTC calendar month, "YYYY-MM". */
export function currentAgentMessagePeriod(now: Date = new Date()): string {
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
}
/** Pure so the send routes and, later, the MCP path share one rule. */
export function checkAgentMessageQuota({ used, limit }: { used: number; limit: number }): {
reached: boolean;
} {
return { reached: used >= limit };
}
export type AgentMessageQuota = { reached: boolean; used: number; limit: number };
/**
* The period counter and the cached plan limit for one org. Fails open: an absent limit
* (self-hosted, or before the cloud side ships) resolves to the unlimited sentinel, and a
* counter read that throws returns `undefined` either way there is no cap.
*/
export async function resolveAgentMessageQuota(
db: DashboardAgentDb,
params: {
organizationId: string;
now?: Date;
readLimit?: (organizationId: string) => Promise<number>;
}
): Promise<AgentMessageQuota | undefined> {
const readLimit =
params.readLimit ??
(async (organizationId: string) => {
// Allowing zero: a plan that includes no messages must cap at 0, not read as absent.
// This call isn't covered directly; the limitValueAllowingZero cases in
// dashboardAgentQuota.test.ts guard the rule it depends on.
const cached = await getCachedLimitAllowingZero(
organizationId,
AGENT_MESSAGE_LIMIT_KEY,
UNLIMITED_AGENT_MESSAGES
);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_AGENT_MESSAGES;
});
try {
const [limit, used] = await Promise.all([
readLimit(params.organizationId),
getAgentMessageUsage(db, {
organizationId: params.organizationId,
period: currentAgentMessagePeriod(params.now),
}),
]);
return { ...checkAgentMessageQuota({ used, limit }), used, limit };
} catch (error) {
logger.error("Failed to resolve dashboard agent message quota", {
organizationId: params.organizationId,
error,
});
return undefined;
}
}
/** Record one sent user message. Swallows errors: the cap is a nudge, never a send blocker. */
export async function recordAgentMessageSent(
db: DashboardAgentDb,
params: { organizationId: string; now?: Date }
): Promise<void> {
try {
await incrementAgentMessageUsage(db, {
organizationId: params.organizationId,
period: currentAgentMessagePeriod(params.now),
});
} catch (error) {
logger.error("Failed to record a dashboard agent message against the quota", {
organizationId: params.organizationId,
error,
});
}
}
/**
* Whether an agent turn consumes quota. Only a genuine new user message counts: the transport
* tags it `trigger: "submit-message"`. A retry/regenerate re-runs the agent from its own history
* without a new message (`trigger: "regenerate-message"`), and a wake is `"action"` neither is
* something the user typed, so neither counts.
*/
export function agentTurnCountsAgainstQuota(
turn: { kind?: string; payload?: { trigger?: string } } | undefined
): boolean {
return turn?.kind === "message" && turn.payload?.trigger === "submit-message";
}
@@ -6,6 +6,7 @@ import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server";
*/
const STATUS_BY_CODE: Record<SubmitWatchErrorCode, number> = {
limit_reached: 409,
watch_limit_reached: 409,
duplicate: 409,
request_conflict: 409,
invalid_target: 404,
@@ -0,0 +1,59 @@
import type { Limits } from "@trigger.dev/platform";
import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts";
import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server";
// The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it
// serializes to null in the limit cache.
export const UNLIMITED_WATCH_LIMIT = 100_000_000;
// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so
// the fallback applies and the plan floor is off.
const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits;
const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits;
export type WatchPlanLimits = {
/** Longest window one watch may run for, in hours. */
maxHours: number;
/** How many active watches the org may run at once. */
watchers: number;
};
async function readLimit(organizationId: string, key: keyof Limits): Promise<number> {
// A plan of 0 means zero, not absent: an org with watches switched off must not read as
// unlimited. Only a missing limit falls open.
const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_WATCH_LIMIT;
}
/**
* The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the
* cloud side ships) resolves to the unlimited sentinel, so neither floor bites. `read` is the
* plan-limit seam: tests pass their own reader instead of the cached platform one.
*/
export async function resolveWatchPlanLimits(
organizationId: string,
read: (organizationId: string, key: keyof Limits) => Promise<number> = readLimit
): Promise<WatchPlanLimits> {
const [maxHours, watchers] = await Promise.all([
read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY),
read(organizationId, WATCH_COUNT_LIMIT_KEY),
]);
return { maxHours, watchers };
}
/**
* The window ceiling actually in force: the plan floor under the code ceiling. A plan that
* allows 100 hours still caps at {@link WATCH_MAX_HOURS}.
*/
export function effectiveWatchMaxHours(planMaxHours: number): number {
return Math.min(planMaxHours, WATCH_MAX_HOURS);
}
/**
* A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never
* hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there.
*/
export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string {
return billingConfigured ? `${base} Upgrade your plan for more.` : base;
}
@@ -10,6 +10,7 @@ import {
cancelWatch,
chatExists,
claimWatchSubmission,
countActiveWatchesForOrg,
createChat,
createWatch,
generateWatchId,
@@ -68,6 +69,12 @@ import {
import { watchCreationCheckDeps } from "~/services/dashboardAgentWatchChecks.server";
import { normalizeErrorFingerprint } from "~/services/dashboardAgentWatchErrorChecks";
import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server";
import {
effectiveWatchMaxHours,
resolveWatchPlanLimits,
watchLimitHint,
type WatchPlanLimits,
} from "~/services/dashboardAgentWatchLimits.server";
import {
mintDashboardAgentWatchBatchToken,
mintDashboardAgentWatchToken,
@@ -165,6 +172,7 @@ export async function authorizeWatchEnvironmentById(params: {
export type CreateWatchErrorCode =
| "limit_reached"
| "watch_limit_reached"
| "duplicate"
| "invalid_target"
| "chat_not_found"
@@ -273,6 +281,12 @@ export async function createDashboardAgentWatch(params: {
scheduleTick?: typeof scheduleWatchTick;
/** Skip the real trigger-config gate when a tick scheduler is injected. */
configured?: () => boolean;
/** Plan floors on window and count. Fails open to unlimited when absent. */
resolveLimits?: (organizationId: string) => Promise<WatchPlanLimits>;
/** Org-wide active-watch count, for the watcher-count floor. */
countActiveWatches?: (organizationId: string) => Promise<number>;
/** Gates the upgrade nudge, so self-hosted stays quiet. */
billingConfigured?: () => boolean;
};
}): Promise<CreateDashboardAgentWatchResult> {
const { environment, userId, chatId } = params;
@@ -284,6 +298,11 @@ export async function createDashboardAgentWatch(params: {
const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps;
const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick;
const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault;
const resolveLimits = params.deps?.resolveLimits ?? resolveWatchPlanLimits;
const countActiveWatches =
params.deps?.countActiveWatches ??
((organizationId: string) => countActiveWatchesForOrg(dashboardAgentDb, { organizationId }));
const hint = (base: string) => watchLimitHint(base, params.deps?.billingConfigured?.());
const checkDeps = buildCheckDeps(environment, now);
if (!isDashboardAgentConfigured()) {
@@ -331,6 +350,29 @@ export async function createDashboardAgentWatch(params: {
return { ok: true, watching: false, identity, immediate };
}
// Both floors are read only now the immediate check didn't answer: a one-shot creates no
// row, so a plan floor must not turn an answerable question into an upgrade nudge. Plan
// floors sit below the code ceilings (min(plan, ceiling)) and fail open: an absent limit
// resolves to unlimited, so neither bites on self-hosted.
const planLimits = await resolveLimits(environment.organizationId);
if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) {
return {
ok: false,
code: "watch_limit_reached",
error: hint("That watch window is longer than your plan allows."),
};
}
// The per-chat cap of 3 still applies independently, in `createWatch`.
const activeCount = await countActiveWatches(environment.organizationId);
if (activeCount >= planLimits.watchers) {
return {
ok: false,
code: "watch_limit_reached",
error: hint("You've reached the number of active watches your plan allows."),
};
}
const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000);
const created = await createWatch(dashboardAgentDb, {
@@ -477,11 +477,48 @@ export function getDefaultEnvironmentLimitFromPlan(
}
export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) {
// No billing client means there is no plan limit to read, so don't touch the cache:
// an unreachable cache Redis would stall the caller for its whole reconnect cycle.
if (!client) return { val: fallback };
return platformCache.limits.swr(`${orgId}:${limit}`, async () => {
return getLimit(orgId, limit, fallback);
});
}
/**
* Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back.
* {@link getLimit} keeps its `!result` fallback, which its callers depend on.
*/
export function limitValueAllowingZero(
limits: Limits | undefined,
limit: keyof Limits,
fallback: number
): number {
const result = limits?.[limit];
if (result === undefined || result === null) return fallback;
if (typeof result === "number") return result;
if (typeof result === "object" && "number" in result) return result.number;
return fallback;
}
/**
* Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it
* never crosses with {@link getCachedLimit}.
*/
export async function getCachedLimitAllowingZero(
orgId: string,
limit: keyof Limits,
fallback: number
) {
if (!client) return { val: fallback };
return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () =>
limitValueAllowingZero(await getLimits(orgId), limit, fallback)
);
}
export async function customerPortalUrl(orgId: string, orgSlug: string) {
if (!client) return undefined;
@@ -397,6 +397,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
...getDefaultClickhouseSettings(),
...queryCacheSettings,
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
readonly: "1", // Not overridable: every query through here is read-only.
},
querySettings: {
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
@@ -0,0 +1,347 @@
import {
appendChatMessageOnceByChatId,
createChat,
createDashboardAgentDb,
getChatMessages,
getSession,
persistMessages,
persistTurn,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect } from "vitest";
/**
* Durability of a chat.agent turn across a crash and a resume, against a real table
* (TRI-11166).
*
* The primitive gives chat.agent durability by snapshotting the transcript and replaying it
* on the next boot. These tests pin the store seam that replay lands on: the completing turn
* re-sends its whole snapshot, so the store has to fold that replay into exactly one row per
* message no double-appended turn, no lost mid-turn message and reconstruct the session
* cursor a refreshed client resumes from.
*
* What is NOT covered here, because it lives inside the closed chat.agent primitive package
* (object-store snapshot write, S2 `.in`/`.out` replay, `.out` trimming, OOM restart): the
* transport-level replay and the snapshot URL's own auth. The client-side reconnect / Last-
* Event-ID replay is covered in packages/trigger-sdk/src/v3/chat.test.ts. These tests are the
* store-level backstop those depend on. See the PR body for the residual follow-ups.
*/
let agentDb: DashboardAgentDb;
let agentDbClient: DashboardAgentDbClient | undefined;
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
async function applyAgentSchema(prisma: PrismaClient) {
for (const name of readdirSync(MIGRATIONS)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
const ORG = "org_resume";
const USER = "user_resume";
async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
agentDb = agentDbClient.db;
await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER });
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
function textMessage(id: string, role: "user" | "assistant" = "assistant", text = id) {
return { id, role, parts: [{ type: "text", text }] };
}
/** A tool part, so a mid-flight call and its completed result share an id but differ in body. */
function toolMessage(id: string, state: "input-available" | "output-available") {
return {
id,
role: "assistant" as const,
parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }],
};
}
async function transcript(chatId: string): Promise<{ id: string }[]> {
return (await getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER })) as {
id: string;
}[];
}
/** The allocator, where a wasted/duplicated slot is observable. */
async function nextPosition(prisma: PrismaClient, chatId: string): Promise<number> {
const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>(
`select next_message_position from trigger_dashboard_agent.chats where id = $1`,
chatId
);
return rows[0]!.next_message_position;
}
async function rowCount(prisma: PrismaClient, chatId: string): Promise<number> {
const rows = await prisma.$queryRawUnsafe<{ count: bigint }[]>(
`select count(*)::int as count from trigger_dashboard_agent.chat_messages where chat_id = $1`,
chatId
);
return Number(rows[0]!.count);
}
describe("a streamed-then-resumed turn is not double-appended", () => {
postgresTest(
"re-delivering the completing turn finalises in place and appends nothing",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_no_double";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
// The turn started: onTurnStart stored the user turn and the tool call mid-flight.
await persistMessages(agentDb, {
chatId,
messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")],
});
expect(await rowCount(prisma, chatId)).toBe(2);
const completing = {
chatId,
messages: [textMessage("u1", "user"), toolMessage("a1", "output-available")],
finalizeMessageIds: ["a1"],
session: { publicAccessToken: "pat", lastEventId: "7", runId: "run" },
};
// The turn completes, replaying its whole snapshot. `a1` is finalised, not re-added.
await persistTurn(agentDb, completing);
// The resume: the same completed turn is delivered again (client reconnected and the
// host re-persisted). It must converge — no second `a1`, no extra row of any kind.
await persistTurn(agentDb, completing);
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
expect(await rowCount(prisma, chatId)).toBe(2);
// Only u1 and a1 ever reserved a slot (allocator starts at 1); the finalisation and the
// replay reserve none, so the next free position is still 3.
expect(await nextPosition(prisma, chatId)).toBe(3);
// And `a1` is the completed body the user saw, not the mid-flight call.
const stored = (await transcript(chatId))[1] as unknown as {
parts: { state: string }[];
};
expect(stored.parts[0]!.state).toBe("output-available");
},
30_000
);
});
describe("a crash mid-turn is reconstructed by the next boot's replay", () => {
postgresTest(
"the resumed turn keeps the mid-turn append, finalises its own message, and rebuilds the session cursor",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_crash_resume";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
// Turn in flight: the snapshot it started from, stored before the model finished.
const snapshot = [textMessage("u1", "user"), toolMessage("a1", "input-available")];
await persistMessages(agentDb, { chatId, messages: snapshot });
// A wake lands mid-turn, off its own lane — the message the old replace-the-array
// write used to lose.
await appendChatMessageOnceByChatId(agentDb, {
chatId,
message: textMessage("wake:w1"),
});
// Before the crash there is no session row to resume from.
expect(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })).toBeNull();
// Boot after the crash: replay the whole transcript, finalise the turn's own message,
// and write the session the client resumes from — all in one persistTurn.
await persistTurn(agentDb, {
chatId,
messages: [
textMessage("u1", "user"),
toolMessage("a1", "output-available"),
textMessage("a2"),
],
finalizeMessageIds: ["a1", "a2"],
session: { publicAccessToken: "pat_resumed", lastEventId: "99", runId: "run_resumed" },
});
// Nothing was lost and the wake sits where it happened: after the snapshot, before the
// reply the turn went on to produce.
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "wake:w1", "a2"]);
const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER });
expect(session).toMatchObject({
publicAccessToken: "pat_resumed",
lastEventId: "99",
runId: "run_resumed",
});
},
30_000
);
});
describe("the session cursor a refreshed client resumes from", () => {
postgresTest(
"getSession returns the last persisted cursor, and a later turn advances it",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_cursor";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
await persistTurn(agentDb, {
chatId,
messages: [textMessage("u1", "user"), textMessage("a1")],
session: { publicAccessToken: "pat1", lastEventId: "10", runId: "run1" },
});
// A mid-stream refresh reads exactly this cursor and resumes .out from it.
expect(
(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }))?.lastEventId
).toBe("10");
// The next turn overwrites the cursor — a stale value is replaced, never appended.
await persistTurn(agentDb, {
chatId,
messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")],
session: { publicAccessToken: "pat2", lastEventId: "25", runId: "run2" },
});
const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER });
expect(session).toMatchObject({
publicAccessToken: "pat2",
lastEventId: "25",
runId: "run2",
});
},
30_000
);
});
describe("a failed snapshot write leaves the next boot a clean replay", () => {
postgresTest(
"a persistTurn that throws mid-write rolls back what it already wrote, and the retry replays with no loss",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_write_fail";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
// A durable first turn, its tool call still mid-flight, and the session cursor it left.
await persistTurn(agentDb, {
chatId,
messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")],
session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" },
});
const positionBefore = await nextPosition(prisma, chatId);
// Tear the next turn at the INSERT itself, so the failure lands after `a1` is finalised
// in place and after the slots are reserved no matter how the store orders its up-front
// validation. A row planted directly at the position the allocator is about to hand out
// makes that insert violate `chat_messages_chat_position_key`. Scaffolding, not part of
// the transcript under test — removed once the tear has fired.
await prisma.$executeRawUnsafe(
`insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message)
values ($1, 'planted_collision', $2, 'assistant', '{}'::jsonb)`,
chatId,
positionBefore
);
// The driver names the failing statement, so the rejection itself pins where the tear fired.
await expect(
persistTurn(agentDb, {
chatId,
messages: [
textMessage("u1", "user"),
toolMessage("a1", "output-available"),
textMessage("a2"),
],
finalizeMessageIds: ["a1"],
session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" },
})
).rejects.toThrow(/Failed query: insert into .*chat_messages/);
await prisma.$executeRawUnsafe(
`delete from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = 'planted_collision'`,
chatId
);
// The whole turn rolled back. The in-place rewrite the store had already applied is undone:
// `a1` is the mid-flight call again, not the finalised body the torn turn wrote.
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]);
const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
expect(tornA1.parts[0]!.state).toBe("input-available");
expect(await rowCount(prisma, chatId)).toBe(2);
// The slot it reserved for `a2` came back too, so the retry doesn't leave a gap.
expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
// The cursor is still the first turn's: the failed turn never got as far as writing one.
expect(
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" });
// The retry — a clean replay of the same turn — lands everything exactly once.
await persistTurn(agentDb, {
chatId,
messages: [
textMessage("u1", "user"),
toolMessage("a1", "output-available"),
textMessage("a2"),
],
finalizeMessageIds: ["a1"],
session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" },
});
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
const retriedA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] };
expect(retriedA1.parts[0]!.state).toBe("output-available");
// One new row, one new slot: the rolled-back reservation was not double-counted.
expect(await nextPosition(prisma, chatId)).toBe(positionBefore + 1);
expect(
await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })
).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" });
},
30_000
);
});
describe("an OOM restart replays the turn cleanly", () => {
postgresTest(
"a restarted turn that re-sends its snapshot loses no data and doubles nothing",
async ({ prisma, postgresContainer }) => {
// The store seam an OOM restart lands on: the primitive restarts the run, replays `.in`,
// and re-persists. `.out` trimming and the OOM restart itself are inside the primitive
// (not reachable here) — this pins that a re-run's re-sent snapshot is idempotent.
const chatId = "chat_oom_restart";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
const firstAttempt = [textMessage("u1", "user"), toolMessage("a1", "input-available")];
await persistMessages(agentDb, { chatId, messages: firstAttempt });
const positionAfterFirst = await nextPosition(prisma, chatId);
// The run OOMs and restarts. It replays the same input, produces the same ids, and
// finalises the turn it now completes.
const restarted = {
chatId,
messages: [
textMessage("u1", "user"),
toolMessage("a1", "output-available"),
textMessage("a2"),
],
finalizeMessageIds: ["a1", "a2"],
session: { publicAccessToken: "pat", lastEventId: "5", runId: "run_restarted" },
};
await persistTurn(agentDb, restarted);
// A second restart delivering the same turn again still converges.
await persistTurn(agentDb, restarted);
expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
// The replayed u1/a1 reserved no new slots; only a2 was genuinely new.
expect(await nextPosition(prisma, chatId)).toBe(positionAfterFirst + 1);
},
30_000
);
});
@@ -0,0 +1,166 @@
import {
createChat,
createDashboardAgentDb,
getInvestigation,
settleInvestigationAndCloseCard,
upsertInvestigationRevision,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import {
investigationStateSchema,
type InvestigationState,
} from "@internal/dashboard-agent-contracts";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, vi } from "vitest";
const ctx = vi.hoisted(() => ({
agentDb: undefined as unknown as DashboardAgentDb,
}));
vi.mock("~/services/dashboardAgentDb.server", () => ({
get dashboardAgentDb() {
return ctx.agentDb;
},
}));
const { sweepDashboardAgentInvestigations, INVESTIGATION_STALE_MS, MAX_SWEEP_ATTEMPTS } =
await import("~/services/dashboardAgentInvestigationSweep.server");
async function applyAgentSchema(prisma: PrismaClient) {
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
const migrations = readdirSync(folder)
.filter((file) => file.endsWith(".sql"))
.sort();
for (const name of migrations) {
const sql = readFileSync(path.join(folder, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
let agentDbClient: DashboardAgentDbClient | undefined;
let prismaForRaw: PrismaClient | undefined;
async function boot(prisma: PrismaClient, connectionUri: string) {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
ctx.agentDb = agentDbClient.db;
prismaForRaw = prisma;
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
const ORG = "org_poison";
const USER = "user_poison";
function openState(): InvestigationState {
return investigationStateSchema.parse({
outcome: "in_progress",
severity: "warn",
confidence: "medium",
title: "a stuck card",
headline: "Still checking.",
progress: "Reading spans",
checkNext: [],
hypotheses: [],
evidence: [],
});
}
async function seedInvestigation(chatId: string, ageMs: number): Promise<string> {
await createChat(ctx.agentDb, { id: chatId, organizationId: ORG, userId: USER });
const created = await upsertInvestigationRevision(ctx.agentDb, {
chatId,
projectRef: "proj",
environmentRef: "env",
state: openState(),
});
if (!created.ok) throw new Error("fixture investigation not created");
await prismaForRaw!.$executeRawUnsafe(
`update trigger_dashboard_agent.investigations
set updated_at = now() - ($2 || ' milliseconds')::interval where id = $1`,
created.id,
String(ageMs)
);
return created.id;
}
async function outcomeOf(id: string): Promise<string | undefined> {
const row = await getInvestigation(ctx.agentDb, { id });
return row ? (row.state as { outcome?: string }).outcome : undefined;
}
const STALE_AGE_MS = INVESTIGATION_STALE_MS + 60_000;
const OLDER_AGE_MS = STALE_AGE_MS + 60_000;
describe("the investigation sweep with a poison row", () => {
postgresTest(
"a row that always fails to settle cannot pin the head and starve a newer row",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
// Poison sorts first (older `updated_at`); renderable is newer.
const poisonId = await seedInvestigation("chat_poison", OLDER_AGE_MS);
const renderableId = await seedInvestigation("chat_ok", STALE_AGE_MS);
// Only the poison row's settle throws; the renderable one goes through the real path.
const settleAndClose = (params: { id: string; chatId: string; note: string }) => {
if (params.id === poisonId) throw new Error("state isn't renderable");
return settleInvestigationAndCloseCard(ctx.agentDb, params);
};
// limit 1 forces head contention: without backoff the poison row would win every run.
// A failed run throws so the job retries, but the attempt is recorded before it does.
await expect(
sweepDashboardAgentInvestigations({ limit: 1, settleAndClose })
).rejects.toThrow();
expect(await outcomeOf(poisonId)).toBe("in_progress");
expect(await outcomeOf(renderableId)).toBe("in_progress");
// Next run: the poison row now sorts behind the never-attempted renderable one,
// so the newer row is picked and settled despite the poison row still being stale.
const second = await sweepDashboardAgentInvestigations({ limit: 1, settleAndClose });
expect(second).toMatchObject({ stale: 1, settled: 1, failed: 0 });
expect(await outcomeOf(renderableId)).toBe("inconclusive");
expect(await outcomeOf(poisonId)).toBe("in_progress");
},
30_000
);
postgresTest(
"after the attempt cap the poison row is abandoned and leaves the queue",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const poisonId = await seedInvestigation("chat_poison", STALE_AGE_MS);
const settleAndClose = () => {
throw new Error("state isn't renderable");
};
// The first MAX_SWEEP_ATTEMPTS-1 runs record a failed attempt and throw; the row stays stale.
for (let i = 1; i < MAX_SWEEP_ATTEMPTS; i++) {
await expect(sweepDashboardAgentInvestigations({ settleAndClose })).rejects.toThrow();
expect(await outcomeOf(poisonId)).toBe("in_progress");
}
// The capped run force-settles the row without the render path, so it leaves the queue.
const capped = await sweepDashboardAgentInvestigations({ settleAndClose });
expect(capped).toMatchObject({ stale: 1, abandoned: 1, failed: 0 });
expect(await outcomeOf(poisonId)).toBe("inconclusive");
// Nothing stale remains, so the poison row is no longer swept.
const after = await sweepDashboardAgentInvestigations({ settleAndClose });
expect(after).toMatchObject({ stale: 0 });
},
30_000
);
});
@@ -0,0 +1,91 @@
import {
createChat,
createDashboardAgentDb,
persistMessages,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server";
/** A lone surrogate in a message: normalized at ingest, and again by the store before jsonb. */
let agentDb: DashboardAgentDb;
let agentDbClient: DashboardAgentDbClient | undefined;
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
async function applyAgentSchema(prisma: PrismaClient) {
for (const name of readdirSync(MIGRATIONS)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
const ORG_ID = "org_surrogate";
const USER_ID = "user_surrogate";
const CHAT_ID = "chat_surrogate";
/** The high half of an emoji whose low half was lost. */
const RAW_TEXT = "why did this run fail \ud83d";
const NORMALIZED_TEXT = "why did this run fail \ufffd";
function userMessage(id: string) {
return { id, role: "user" as const, parts: [{ type: "text", text: RAW_TEXT }] };
}
async function boot(prisma: PrismaClient, connectionUri: string) {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
agentDb = agentDbClient.db;
await createChat(agentDb, { id: CHAT_ID, organizationId: ORG_ID, userId: USER_ID });
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
async function storedText(prisma: PrismaClient, messageId: string): Promise<string> {
const rows = await prisma.$queryRawUnsafe<{ text: string }[]>(
`select message -> 'parts' -> 0 ->> 'text' as text
from trigger_dashboard_agent.chat_messages
where chat_id = $1 and message_id = $2`,
CHAT_ID,
messageId
);
return rows[0]!.text;
}
describe("a lone surrogate in a message", () => {
postgresTest(
"reaches the store raw and still persists, normalized",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await persistMessages(agentDb, { chatId: CHAT_ID, messages: [userMessage("raw")] });
expect(await storedText(prisma, "raw")).toBe(NORMALIZED_TEXT);
},
30_000
);
// The store normalizes too, so only the parts themselves show what ingest did \u2014 and it is
// the parts the model is handed.
test("is gone from the parts ingest hands on", () => {
const message = userMessage("ingest");
wellFormMessageText(message.parts);
expect(message.parts[0]!.text).toBe(NORMALIZED_TEXT);
});
});
@@ -0,0 +1,210 @@
import {
createChat,
createDashboardAgentDb,
getAgentMessageUsage,
incrementAgentMessageUsage,
softDeleteChat,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
agentTurnCountsAgainstQuota,
checkAgentMessageQuota,
currentAgentMessagePeriod,
resolveAgentMessageQuota,
UNLIMITED_AGENT_MESSAGES,
} from "~/services/dashboardAgentQuota.server";
import { limitValueAllowingZero } from "~/services/platform.v3.server";
/**
* Server-side agent message quota (TRI-12863): a per-(org, period) counter that a deleted chat
* can't lower, a pure at/over/under rule, and a resolver that fails open when the limit is
* absent (self-hosted) or the counter read throws.
*/
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
async function applyAgentSchema(prisma: PrismaClient) {
for (const name of readdirSync(MIGRATIONS)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
const ORG = "org_quota";
const USER = "user_quota";
let agentDbClient: DashboardAgentDbClient | undefined;
async function boot(prisma: PrismaClient, connectionUri: string): Promise<DashboardAgentDb> {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
return agentDbClient.db;
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
describe("checkAgentMessageQuota", () => {
it("is not reached under the limit", () => {
expect(checkAgentMessageQuota({ used: 5, limit: 20 })).toEqual({ reached: false });
});
it("is reached at the limit", () => {
// Control break: `>=`. Flip to `>` and this fails.
expect(checkAgentMessageQuota({ used: 20, limit: 20 })).toEqual({ reached: true });
});
it("is reached over the limit", () => {
expect(checkAgentMessageQuota({ used: 21, limit: 20 })).toEqual({ reached: true });
});
it("is never reached against the unlimited sentinel", () => {
expect(checkAgentMessageQuota({ used: 10_000, limit: UNLIMITED_AGENT_MESSAGES })).toEqual({
reached: false,
});
});
});
describe("agentTurnCountsAgainstQuota", () => {
it("counts a genuine new user message (submit-message)", () => {
expect(
agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "submit-message" } })
).toBe(true);
});
it("does not count a retry/regenerate", () => {
// Control break: a regenerate re-runs from history with no new message, so it must not
// burn quota. Widen the rule back to `!== "action"` and this fails.
expect(
agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "regenerate-message" } })
).toBe(false);
});
it("does not count a wake (action turn)", () => {
expect(agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "action" } })).toBe(
false
);
});
it("does not count a non-message turn or a missing body", () => {
expect(agentTurnCountsAgainstQuota({ kind: "action" })).toBe(false);
expect(agentTurnCountsAgainstQuota(undefined)).toBe(false);
});
});
describe("currentAgentMessagePeriod", () => {
it("is a zero-padded UTC calendar month", () => {
expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 7, 9)))).toBe("2026-08");
expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 0, 1)))).toBe("2026-01");
});
});
describe("the per-(org, period) counter", () => {
postgresTest(
"accumulates and a deleted chat cannot free quota within the period",
async ({ prisma, postgresContainer }) => {
const db = await boot(prisma, postgresContainer.getConnectionUri());
const period = "2026-08";
// The create path and then an append: two messages, same period.
expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(1);
expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2);
expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2);
// Deleting a chat must not move the counter: it is not joined to chats.
await createChat(db, { id: "chat_del", organizationId: ORG, userId: USER });
await softDeleteChat(db, { chatId: "chat_del", userId: USER, organizationId: ORG });
expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2);
// The next period and other orgs start fresh.
expect(await getAgentMessageUsage(db, { organizationId: ORG, period: "2026-09" })).toBe(0);
expect(await getAgentMessageUsage(db, { organizationId: "org_other", period })).toBe(0);
}
);
});
describe("resolveAgentMessageQuota", () => {
postgresTest(
"reports reached over the limit, and never reached when unlimited",
async ({ prisma, postgresContainer }) => {
const db = await boot(prisma, postgresContainer.getConnectionUri());
const now = new Date();
const period = currentAgentMessagePeriod(now);
for (let i = 0; i < 3; i++) {
await incrementAgentMessageUsage(db, { organizationId: ORG, period });
}
expect(
await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 3 })
).toEqual({
reached: true,
used: 3,
limit: 3,
});
expect(
await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 20 })
).toEqual({ reached: false, used: 3, limit: 20 });
// Self-hosted: the limit is absent, so the fallback (unlimited sentinel) applies and there
// is no cap — no extra branching, it falls out of the fallback.
const selfHosted = await resolveAgentMessageQuota(db, {
organizationId: ORG,
now,
readLimit: async () => UNLIMITED_AGENT_MESSAGES,
});
expect(selfHosted?.reached).toBe(false);
}
);
postgresTest(
"a plan allowance of zero caps immediately, it is not read as unlimited",
async ({ prisma, postgresContainer }) => {
const db = await boot(prisma, postgresContainer.getConnectionUri());
const now = new Date();
expect(
await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 0 })
).toEqual({ reached: true, used: 0, limit: 0 });
// The read the default limit path performs. Control break: with the `!result` fallback
// of `getCachedLimit`, a plan value of 0 comes back as the unlimited sentinel.
expect(
limitValueAllowingZero(
{ agentMessages: 0 } as never,
"agentMessages" as never,
UNLIMITED_AGENT_MESSAGES
)
).toBe(0);
expect(
limitValueAllowingZero(undefined, "agentMessages" as never, UNLIMITED_AGENT_MESSAGES)
).toBe(UNLIMITED_AGENT_MESSAGES);
}
);
it("fails open when the counter read throws", async () => {
const throwingDb = {
select: () => {
throw new Error("db down");
},
} as unknown as DashboardAgentDb;
const result = await resolveAgentMessageQuota(throwingDb, {
organizationId: ORG,
readLimit: async () => 5,
});
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,150 @@
import {
appendChatMessageOnce,
createChat,
createDashboardAgentDb,
getChatMessages,
persistMessages,
persistTurn,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect } from "vitest";
/**
* jsonb rejects a lone UTF-16 surrogate, and a message body carries strings we never
* authored tool inputs, filenames, urls from transports that don't pass the webapp
* routes. `storeChatMessages` and `appendOneMessage` are where they have to be made storable.
*/
let agentDb: DashboardAgentDb;
let agentDbClient: DashboardAgentDbClient | undefined;
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
async function applyAgentSchema(prisma: PrismaClient) {
for (const name of readdirSync(MIGRATIONS)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
const ORG = "org_surrogate";
const USER = "user_surrogate";
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
agentDb = agentDbClient.db;
await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER });
}
async function transcript(chatId: string): Promise<unknown[]> {
return getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER });
}
describe("a lone surrogate anywhere in a message body is storable", () => {
postgresTest(
"persists a tool input carrying a lone surrogate",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_surrogate";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
await persistMessages(agentDb, {
chatId,
messages: [
{
id: "u1",
role: "user",
parts: [
{
type: "tool-search_docs",
state: "input-available",
toolCallId: "u1_call",
input: { query: "how do i \ud83d", filename: "\udc00.png" },
},
],
},
],
});
const stored = (await transcript(chatId)) as {
parts: { input: { query: string; filename: string } }[];
}[];
expect(stored).toHaveLength(1);
expect(stored[0]!.parts[0]!.input.query).toBe("how do i ");
expect(stored[0]!.parts[0]!.input.filename).toBe(".png");
},
30_000
);
postgresTest(
"finalises a tool output carrying a lone surrogate",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_surrogate_turn";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
const call = (state: string, extra: Record<string, unknown>) => ({
id: "a1",
role: "assistant",
parts: [{ type: "tool-search_docs", state, toolCallId: "a1_call", input: {}, ...extra }],
});
// Stored mid-flight by `onTurnStart`, then rewritten in place when the turn completes.
await persistMessages(agentDb, { chatId, messages: [call("input-available", {})] });
await persistTurn(agentDb, {
chatId,
messages: [call("output-available", { output: { text: "the page says \ud83d" } })],
finalizeMessageIds: ["a1"],
session: { publicAccessToken: "pat", lastEventId: "1", runId: "run" },
});
const stored = (await transcript(chatId)) as {
parts: { state: string; output: { text: string } }[];
}[];
expect(stored).toHaveLength(1);
expect(stored[0]!.parts[0]!.state).toBe("output-available");
expect(stored[0]!.parts[0]!.output.text).toBe("the page says ");
},
30_000
);
postgresTest(
"appends a wake message carrying a lone surrogate",
async ({ prisma, postgresContainer }) => {
const chatId = "chat_surrogate_append";
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
const appended = await appendChatMessageOnce(agentDb, {
chatId,
userId: USER,
organizationId: ORG,
message: {
id: "w1",
role: "assistant",
parts: [{ type: "text", text: "the queue \udc00 backed up" }],
} as { id: string; role: string },
});
expect(appended).toBe(true);
const stored = (await transcript(chatId)) as { parts: { text: string }[] }[];
expect(stored[0]!.parts[0]!.text).toBe("the queue backed up");
},
30_000
);
});
@@ -0,0 +1,240 @@
import {
appendChatMessageOnce,
chatExists,
countUserMessages,
createChat,
createDashboardAgentDb,
getChatMessages,
getSession,
listChats,
persistTurn,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect } from "vitest";
/**
* Cross-tenant isolation for the chat store, against a real table (TRI-11166).
*
* The 2026-06-10 chat.agent audit flagged a cross-tenant read: a chat/session belongs to
* one (org, user) pair, and every read that hands back its transcript or its session token
* has to be scoped by that pair. A chatId from another tenant must read as not-found never
* as another tenant's transcript, and never as another tenant's public access token, which
* is the credential a resumed session boots from.
*
* The store's own queries are the floor: the resource route scopes on project.organizationId
* above this, but a bug there would still be caught here because these queries refuse a
* foreign (org, user) outright rather than trusting the caller.
*/
let agentDb: DashboardAgentDb;
let agentDbClient: DashboardAgentDbClient | undefined;
const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
async function applyAgentSchema(prisma: PrismaClient) {
for (const name of readdirSync(MIGRATIONS)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
// Org A owns the chat. Org B and a same-org other user are the foreign tenants.
const ORG_A = "org_a";
const USER_A = "user_a";
const ORG_B = "org_b";
const USER_B = "user_b";
const CHAT = "chat_owned_by_a";
async function boot(prisma: PrismaClient, connectionUri: string) {
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
agentDb = agentDbClient.db;
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
function textMessage(id: string, role: "user" | "assistant" = "assistant") {
return { id, role, parts: [{ type: "text", text: id }] };
}
/** Seed a chat under org A with a transcript and a live session (its PAT is the credential). */
async function seedOwnedChat() {
await createChat(agentDb, { id: CHAT, organizationId: ORG_A, userId: USER_A });
await persistTurn(agentDb, {
chatId: CHAT,
messages: [textMessage("u1", "user"), textMessage("a1")],
session: { publicAccessToken: "pat_secret_of_a", lastEventId: "42", runId: "run_a" },
});
}
const foreignScopes = [
{ name: "another org", organizationId: ORG_B, userId: USER_B },
// Same org, different user: a member of A's org still isn't the chat's owner.
{ name: "another user in the same org", organizationId: ORG_A, userId: USER_B },
// Right user id, wrong org: the id alone must not carry across a tenant boundary.
{ name: "the owner's user id under another org", organizationId: ORG_B, userId: USER_A },
];
describe("getChatMessages is scoped to the owning (org, user)", () => {
postgresTest(
"the owner reads the transcript; every foreign tenant reads not-found",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await seedOwnedChat();
const owned = await getChatMessages(agentDb, {
chatId: CHAT,
organizationId: ORG_A,
userId: USER_A,
});
expect((owned as { id: string }[]).map((m) => m.id)).toEqual(["u1", "a1"]);
for (const scope of foreignScopes) {
// null is not-found. It must never be [] (a visible-but-empty chat) and never A's rows.
const seen = await getChatMessages(agentDb, {
chatId: CHAT,
organizationId: scope.organizationId,
userId: scope.userId,
});
expect(seen, scope.name).toBeNull();
}
},
30_000
);
});
describe("getSession never hands a foreign tenant the owner's access token", () => {
postgresTest(
"the owner gets the session; every foreign tenant gets null",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await seedOwnedChat();
const owned = await getSession(agentDb, {
chatId: CHAT,
organizationId: ORG_A,
userId: USER_A,
});
expect(owned?.publicAccessToken).toBe("pat_secret_of_a");
for (const scope of foreignScopes) {
const seen = await getSession(agentDb, {
chatId: CHAT,
organizationId: scope.organizationId,
userId: scope.userId,
});
// A leaked session row would carry A's PAT — the resume credential. Refuse outright.
expect(seen, scope.name).toBeNull();
}
},
30_000
);
});
describe("chatExists is the owner check the action routes gate on", () => {
postgresTest(
"true for the owner, false for every foreign tenant",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await seedOwnedChat();
expect(
await chatExists(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A })
).toBe(true);
for (const scope of foreignScopes) {
expect(
await chatExists(agentDb, {
chatId: CHAT,
organizationId: scope.organizationId,
userId: scope.userId,
}),
scope.name
).toBe(false);
}
},
30_000
);
});
describe("listChats and countUserMessages never surface another tenant's chat", () => {
postgresTest(
"a foreign tenant lists nothing and counts nothing of the owner's",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await seedOwnedChat();
const ownedList = await listChats(agentDb, { organizationId: ORG_A, userId: USER_A });
expect(ownedList.map((c) => c.id)).toEqual([CHAT]);
expect(await countUserMessages(agentDb, { organizationId: ORG_A, userId: USER_A })).toBe(1);
for (const scope of foreignScopes) {
const list = await listChats(agentDb, {
organizationId: scope.organizationId,
userId: scope.userId,
});
expect(list, scope.name).toEqual([]);
expect(
await countUserMessages(agentDb, {
organizationId: scope.organizationId,
userId: scope.userId,
}),
scope.name
).toBe(0);
}
},
30_000
);
});
describe("a foreign org cannot append to another tenant's chat", () => {
postgresTest(
"appendChatMessageOnce with a foreign org writes nothing and leaves the transcript intact",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
await seedOwnedChat();
const before = await getChatMessages(agentDb, {
chatId: CHAT,
organizationId: ORG_A,
userId: USER_A,
});
// A chat id from another org appends nothing when the org is verified.
const wroteForeignOrg = await appendChatMessageOnce(agentDb, {
chatId: CHAT,
userId: USER_A,
organizationId: ORG_B,
message: { id: "intruder", role: "assistant" },
});
expect(wroteForeignOrg).toBe(false);
// And a foreign user, same org, is refused too.
const wroteForeignUser = await appendChatMessageOnce(agentDb, {
chatId: CHAT,
userId: USER_B,
organizationId: ORG_A,
message: { id: "intruder2", role: "assistant" },
});
expect(wroteForeignUser).toBe(false);
expect(
await getChatMessages(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A })
).toEqual(before);
},
30_000
);
});
@@ -0,0 +1,163 @@
import {
createDashboardAgentDb,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, vi } from "vitest";
import type * as WatchLimitsModule from "~/services/dashboardAgentWatchLimits.server";
// A plan-limit refusal (`watch_limit_reached`) is a 409, not a 500. The card submit's status
// ladder must map it the same way the MCP route does, or a full org sees an "unexpected error".
const ctx = vi.hoisted(() => ({
prisma: undefined as unknown as PrismaClient,
agentDb: undefined as unknown as DashboardAgentDb,
userId: "",
}));
vi.mock("~/db.server", () => {
const proxy = new Proxy(
{},
{ get: (_target, prop) => (ctx.prisma as unknown as Record<string, unknown>)[prop as string] }
);
return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined };
});
vi.mock("~/services/session.server", () => ({
requireUser: async () => ({ id: ctx.userId, admin: false, isImpersonating: false }),
}));
vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
canAccessDashboardAgent: async () => true,
}));
vi.mock("~/services/dashboardAgentDb.server", () => ({
get dashboardAgentDb() {
return ctx.agentDb;
},
}));
// The only stub: the plan floor billing would resolve. A 1-hour window makes a 2-hour watch
// exceed the plan, so the real submit path returns `watch_limit_reached`. Everything else runs.
vi.mock("~/services/dashboardAgentWatchLimits.server", async (importOriginal) => {
const actual = await importOriginal<typeof WatchLimitsModule>();
return {
...actual,
resolveWatchPlanLimits: async () => ({
maxHours: 1,
watchers: actual.UNLIMITED_WATCH_LIMIT,
}),
};
});
process.env.SESSION_SECRET = "test-session-secret-for-watch-limit-status";
// Unset, watch creation stops at `not_configured` (501) before the plan floor is read.
process.env.DASHBOARD_AGENT_SECRET_KEY = "test-dashboard-agent-secret";
const { action } =
await import("~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent");
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
async function applyAgentSchema(prisma: PrismaClient) {
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
for (const name of readdirSync(folder)
.filter((file) => file.endsWith(".sql"))
.sort()) {
const sql = readFileSync(path.join(folder, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
let agentDbClient: DashboardAgentDbClient | undefined;
async function seed(prisma: PrismaClient) {
const slug = `limit_status_${Math.random().toString(36).slice(2, 10)}`;
const user = await prisma.user.create({
data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({ data: { title: slug, slug } });
await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
});
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` },
});
await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `p${slug.slice(0, 6)}`,
},
});
ctx.userId = user.id;
return { user, organization, project };
}
// error_recurrence resolves its target with no run/queue read, so the plan floor is the only
// thing standing between a valid submit and a created watch.
const DRAFT = JSON.stringify({
spec: {
kind: "error_recurrence",
fingerprint: "a1b2c3",
checkEveryMinutes: 5,
maxHours: 2,
note: "ping me if it happens again",
},
followUp: { investigateOnAttention: false, notifyExternally: false },
});
function submitRequest(slug: string, body: Record<string, string>) {
const form = new URLSearchParams(body);
return action({
request: new Request(
`https://app.trigger.dev/resources/orgs/${slug}/projects/${slug}/env/prod/dashboard-agent`,
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form.toString(),
}
),
params: { organizationSlug: slug, projectParam: slug, envParam: "prod" },
context: {},
} as never) as Promise<Response>;
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
describe("the watch card submit's status for a plan-limit refusal", () => {
postgresTest(
"answers 409, not 500, when the window is longer than the plan allows",
async ({ prisma, postgresContainer }) => {
ctx.prisma = prisma;
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(postgresContainer.getConnectionUri(), { max: 4 });
ctx.agentDb = agentDbClient.db;
const seeded = await seed(prisma);
const response = await submitRequest(seeded.organization.slug, {
intent: "watch-create",
draft: DRAFT,
clientRequestId: "wreq_limit_1",
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ code: "watch_limit_reached" });
},
30_000
);
});
@@ -0,0 +1,411 @@
import {
countActiveWatchesForOrg,
createChat,
createDashboardAgentDb,
listActiveWatchesForChat,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import type { WatchSpec } from "@internal/dashboard-agent-contracts";
import type * as TriggerSdk from "@trigger.dev/sdk";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WatchCheckDeps, WatchRunRow } from "~/services/dashboardAgentWatchChecks";
import type { WatchPlanLimits } from "~/services/dashboardAgentWatchLimits.server";
vi.setConfig({ testTimeout: 60_000 });
const ctx = vi.hoisted(() => ({
prisma: undefined as unknown as PrismaClient,
agentDb: undefined as unknown as DashboardAgentDb,
}));
vi.mock("~/db.server", () => {
const proxy = new Proxy(
{},
{ get: (_target, prop) => (ctx.prisma as unknown as Record<string, unknown>)[prop as string] }
);
return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined };
});
vi.mock("~/services/dashboardAgentDb.server", () => ({
get dashboardAgentDb() {
return ctx.agentDb;
},
}));
vi.mock("@trigger.dev/sdk", async (importOriginal) => {
const actual = await importOriginal<typeof TriggerSdk>();
return {
...actual,
TriggerClient: class {
tasks = { trigger: async () => ({ id: "run_test" }) };
},
};
});
process.env.SESSION_SECRET = "test-session-secret-for-watch-limits";
const { createDashboardAgentWatch } = await import("~/services/dashboardAgentWatches.server");
const { effectiveWatchMaxHours, resolveWatchPlanLimits, watchLimitHint, UNLIMITED_WATCH_LIMIT } =
await import("~/services/dashboardAgentWatchLimits.server");
const { limitValueAllowingZero } = await import("~/services/platform.v3.server");
async function applyAgentSchema(prisma: PrismaClient) {
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
const migrations = readdirSync(folder)
.filter((file) => file.endsWith(".sql"))
.sort();
for (const name of migrations) {
const sql = readFileSync(path.join(folder, name), "utf8");
for (const statement of sql.split("--> statement-breakpoint")) {
const trimmed = statement.trim();
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
}
}
}
let agentDbClient: DashboardAgentDbClient | undefined;
async function boot(prisma: PrismaClient, connectionUri: string) {
ctx.prisma = prisma;
await applyAgentSchema(prisma);
agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
ctx.agentDb = agentDbClient.db;
}
async function seed(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const user = await prisma.user.create({
data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({ data: { title: slug, slug } });
await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
});
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` },
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `p${slug.slice(0, 6)}`,
},
});
return { user, organization, project, environment };
}
type Seeded = Awaited<ReturnType<typeof seed>>;
function authenticated(seeded: Seeded) {
return {
id: seeded.environment.id,
organizationId: seeded.organization.id,
projectId: seeded.project.id,
slug: "prod",
type: "PRODUCTION",
project: { id: seeded.project.id, externalRef: seeded.project.externalRef },
organization: { id: seeded.organization.id, slug: seeded.organization.slug },
} as any;
}
async function seedChat(seeded: Seeded, chatId: string) {
await createChat(ctx.agentDb, {
id: chatId,
organizationId: seeded.organization.id,
userId: seeded.user.id,
});
return chatId;
}
function runRow(overrides: Partial<WatchRunRow> = {}): WatchRunRow {
return {
friendlyId: "run_1",
status: "PENDING",
queue: "task/my-task",
createdAt: new Date(),
queuedAt: null,
startedAt: null,
completedAt: null,
delayUntil: null,
...overrides,
};
}
function fakeCheckDeps(overrides: Partial<WatchCheckDeps> = {}): WatchCheckDeps {
return {
readRun: async () => runRow(),
queueExists: async () => true,
readQueueDepth: async () => ({ depth: 7, source: "live_queue", current: true }),
readQueueOldestAge: async () => ({ ageMs: 30_000, source: "live_queue", current: true }),
readErrorRecurrence: async () => null,
readHealth: async () => ({ trustworthy: true, severity: "warn" }),
...overrides,
};
}
const UNLIMITED: WatchPlanLimits = {
maxHours: UNLIMITED_WATCH_LIMIT,
watchers: UNLIMITED_WATCH_LIMIT,
};
function runStart(runId: string, maxHours = 2): WatchSpec {
return { kind: "run_start", runId, checkEveryMinutes: 1, maxHours, note: "tell me" };
}
function create(args: {
seeded: Seeded;
spec: WatchSpec;
chatId: string;
limits?: WatchPlanLimits;
billingConfigured?: boolean;
countActiveWatches?: (organizationId: string) => Promise<number>;
checkDeps?: Partial<WatchCheckDeps>;
}) {
return createDashboardAgentWatch({
environment: authenticated(args.seeded),
userId: args.seeded.user.id,
chatId: args.chatId,
spec: args.spec,
deps: {
configured: () => true,
checkDeps: () => fakeCheckDeps(args.checkDeps),
scheduleTick: async () => {},
resolveLimits: async () => args.limits ?? UNLIMITED,
...(args.countActiveWatches ? { countActiveWatches: args.countActiveWatches } : {}),
...(args.billingConfigured === undefined
? {}
: { billingConfigured: () => args.billingConfigured! }),
},
});
}
afterEach(async () => {
await agentDbClient?.close();
agentDbClient = undefined;
});
describe("watch plan limits (pure)", () => {
it("caps the window ceiling at the code ceiling of 24 hours", () => {
expect(effectiveWatchMaxHours(100)).toBe(24);
expect(effectiveWatchMaxHours(1)).toBe(1);
expect(effectiveWatchMaxHours(0.5)).toBe(0.5);
});
it("reads a plan limit of zero as zero, not as an absent limit", async () => {
// The read the cached platform limit performs: a plan that switched watches off must not
// fall back to the unlimited sentinel.
expect(
limitValueAllowingZero(
{ agentWatchMaxHours: 0 } as never,
"agentWatchMaxHours" as never,
UNLIMITED_WATCH_LIMIT
)
).toBe(0);
expect(
limitValueAllowingZero(undefined, "agentWatchMaxHours" as never, UNLIMITED_WATCH_LIMIT)
).toBe(UNLIMITED_WATCH_LIMIT);
expect(await resolveWatchPlanLimits("org_1", async () => 0)).toEqual({
maxHours: 0,
watchers: 0,
});
});
it("adds an upgrade nudge only when billing is configured", () => {
expect(watchLimitHint("too long.", true)).toBe("too long. Upgrade your plan for more.");
expect(watchLimitHint("too long.", false)).toBe("too long.");
});
});
describe("createDashboardAgentWatch plan enforcement", () => {
postgresTest(
"refuses a window longer than the plan allows",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "window");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 2),
limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT },
billingConfigured: true,
});
expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" });
if (result.ok) return;
expect(result.error).toContain("Upgrade your plan");
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0);
}
);
postgresTest(
"creates a watch whose window is within the plan",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "within");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 1),
limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT },
});
expect(result.ok).toBe(true);
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1);
}
);
postgresTest(
"refuses once the org is at its watcher count, counting active watches for real",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "count");
await seedChat(seeded, "chat_1");
await seedChat(seeded, "chat_2");
const limits: WatchPlanLimits = { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 1 };
const first = await create({ seeded, chatId: "chat_1", spec: runStart("run_1"), limits });
expect(first.ok).toBe(true);
expect(
await countActiveWatchesForOrg(ctx.agentDb, { organizationId: seeded.organization.id })
).toBe(1);
const second = await create({ seeded, chatId: "chat_2", spec: runStart("run_2"), limits });
expect(second).toMatchObject({ ok: false, code: "watch_limit_reached" });
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_2" })).toHaveLength(0);
}
);
postgresTest(
"fails open: an absent limit resolves to unlimited and a 2h watch is created",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "failopen");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 2),
limits: UNLIMITED,
});
expect(result.ok).toBe(true);
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1);
}
);
postgresTest(
"leaves no upgrade nudge on a refusal when billing is unconfigured",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "selfhosted");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 2),
limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT },
billingConfigured: false,
});
expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" });
if (result.ok) return;
expect(result.error).not.toContain("Upgrade");
}
);
postgresTest(
"a plan window of zero hours refuses every watch",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "zerohours");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 1),
limits: { maxHours: 0, watchers: UNLIMITED_WATCH_LIMIT },
});
expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" });
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0);
}
);
postgresTest(
"a plan of zero watchers refuses creation",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "zerowatchers");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 1),
limits: { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 0 },
});
expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" });
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0);
}
);
postgresTest(
"answers a condition that already happened, instead of refusing the window",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "instant");
await seedChat(seeded, "chat_1");
const result = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 2),
limits: { maxHours: 1, watchers: 0 },
billingConfigured: true,
checkDeps: {
readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }),
},
});
expect(result).toMatchObject({ ok: true, watching: false });
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0);
}
);
postgresTest(
"min semantics: a plan of 100 hours still permits only up to the 24h ceiling",
async ({ prisma, postgresContainer }) => {
await boot(prisma, postgresContainer.getConnectionUri());
const seeded = await seed(prisma, "minsem");
await seedChat(seeded, "chat_1");
const created = await create({
seeded,
chatId: "chat_1",
spec: runStart("run_1", 24),
limits: { maxHours: 100, watchers: UNLIMITED_WATCH_LIMIT },
});
expect(created.ok).toBe(true);
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1);
}
);
});
+181
View File
@@ -0,0 +1,181 @@
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The query API is read-only, and the grammar is what enforces it. A parser test alone would
* stay green if the route ever compiled agent SQL somewhere else, so these drive the real route
* with a real signed environment JWT and stub only the ClickHouse client. A write must be
* refused before anything reaches ClickHouse.
*/
const ENVIRONMENT_ID = "env_1234";
const API_KEY = "tr_dev_abcdefghijklmnop";
const environment = {
id: ENVIRONMENT_ID,
type: "DEVELOPMENT",
slug: "dev",
branchName: null,
apiKey: API_KEY,
organizationId: "org_1",
projectId: "proj_1",
archivedAt: null,
concurrencyLimitBurstFactor: { toNumber: () => 1 },
maximumConcurrencyLimit: 10,
project: { id: "proj_1", externalRef: "proj_ref", deletedAt: null },
organization: { id: "org_1" },
orgMember: null,
parentEnvironment: null,
};
const mocks = vi.hoisted(() => ({
runtimeEnvironmentFindFirst: vi.fn(),
queryWithStats: vi.fn(),
customerQueryCreate: vi.fn(),
concurrencyAcquire: vi.fn(),
}));
vi.mock("~/db.server", () => {
const client = {
runtimeEnvironment: {
findFirst: mocks.runtimeEnvironmentFindFirst,
findMany: async () => [],
},
revokedApiKey: { findMany: async () => [], findFirst: async () => null },
project: { findMany: async () => [] },
customerQuery: { findFirst: async () => null, create: mocks.customerQueryCreate },
};
return { prisma: client, $replica: client };
});
vi.mock("~/env.server", () => ({
env: {
SESSION_SECRET: "test-session-secret",
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: "30",
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: 1000000,
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: 50000,
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: 500000,
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: 1000000,
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: 1000,
},
}));
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
clickhouseFactory: {
getClickhouseForOrganization: async () => ({
reader: { queryWithStats: mocks.queryWithStats },
}),
},
}));
vi.mock("~/services/platform.v3.server", () => ({ getLimit: async () => 30 }));
vi.mock("~/services/queryConcurrencyLimiter.server", () => ({
queryConcurrencyLimiter: {
acquire: mocks.concurrencyAcquire,
release: async () => {},
},
DEFAULT_ORG_CONCURRENCY_LIMIT: 10,
GLOBAL_CONCURRENCY_LIMIT: 100,
}));
vi.mock("~/services/logger.server", () => ({
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({
WorkerGroupTokenService: class {},
}));
vi.mock("~/v3/services/common.server", () => ({ ServiceValidationError: class extends Error {} }));
vi.mock("@internal/run-engine", () => ({ EngineServiceValidationError: class extends Error {} }));
import { action } from "~/routes/api.v1.query";
import { executeQuery } from "~/services/queryService.server";
/** The claims the env-JWT exchange mints (api.v1.projects.$projectRef.$env.jwt.ts). */
function mintEnvJwt(scopes: string[]) {
return generateJWT({
secretKey: API_KEY,
payload: {
sub: ENVIRONMENT_ID,
pub: true,
scopes,
act: { sub: "usr_1", client: "dashboard-agent" },
},
expirationTime: "1h",
});
}
async function runQuery(query: string): Promise<{ status: number; body: any }> {
const jwt = await mintEnvJwt(["read:query"]);
const response = await action({
request: new Request("https://api.trigger.dev/api/v1/query", {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
body: JSON.stringify({ query }),
}),
params: {},
context: {},
} as any);
return { status: response.status, body: await response.json() };
}
describe("the query API route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.runtimeEnvironmentFindFirst.mockResolvedValue(environment);
mocks.customerQueryCreate.mockResolvedValue({ id: "cq_1" });
mocks.concurrencyAcquire.mockResolvedValue({ success: true });
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
});
// Pins the seam the two refusals assert against: a read really does reach ClickHouse here,
// so `not.toHaveBeenCalled()` below means refused, not unreachable.
it("runs a read against ClickHouse", async () => {
const result = await runQuery("SELECT count() FROM runs");
expect(result.status).toBe(200);
expect(mocks.queryWithStats).toHaveBeenCalled();
});
it("refuses a write smuggled in as a second statement", async () => {
const result = await runQuery("SELECT 1 FROM runs; DROP TABLE runs");
expect(result.status).toBe(400);
expect(mocks.queryWithStats).not.toHaveBeenCalled();
});
it("refuses a mutating statement", async () => {
const result = await runQuery("INSERT INTO runs (task_identifier) VALUES ('x')");
expect(result.status).toBe(400);
expect(mocks.queryWithStats).not.toHaveBeenCalled();
});
// A busy service is not a bad query: 400 would tell a caller to rewrite a query that was fine.
it("answers a concurrency rejection with 429", async () => {
mocks.concurrencyAcquire.mockResolvedValue({ success: false, reason: "key_limit" });
const result = await runQuery("SELECT count() FROM runs");
expect(result.status).toBe(429);
expect(result.body.error).toContain("try again later");
});
});
describe("the query service", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.concurrencyAcquire.mockResolvedValue({ success: true });
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
});
it("keeps ClickHouse read-only when a caller overrides the settings", async () => {
await executeQuery({
name: "test-query",
query: "SELECT count() FROM runs",
scope: "environment",
organizationId: "org_1",
projectId: "proj_1",
environmentId: ENVIRONMENT_ID,
clickhouseSettings: { readonly: "0" },
} as any);
expect(mocks.queryWithStats).toHaveBeenCalled();
expect(mocks.queryWithStats.mock.calls[0][0].settings.readonly).toBe("1");
});
});
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
"app/components/code/**/*.test.ts",
"app/components/dashboard-agent/**/*.test.ts",
"app/components/queues/**/*.test.ts",
"app/routes/storybook.agent-ui/*.test.ts",
"app/presenters/v3/reports/**/*.test.ts",
],
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
+10 -9
View File
@@ -215,10 +215,12 @@ function ChatClient({ chatId, initialMessages, initialSessions }) {
</Info>
<Note>
After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend because
the AI SDK doesn't pass its abort signal through `reconnectToStream`. Use
`transport.stopGeneration(chatId)` for reliable stop behavior after resume — see
[Stop generation](#stop-generation) for the recommended pattern.
After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend. The
transport accepts an abort signal on `reconnectToStream` but doesn't treat it as owning the turn,
so aborting a resumed subscription only closes your local stream while the run keeps generating.
Use `transport.stopGeneration(chatId)` for reliable stop behavior after resume — see
[Stop generation](#stop-generation) for the recommended pattern. Pass `stopOnAbort: true` to
`reconnectToStream` only when that subscriber owns the turn.
</Note>
<Warning>
@@ -340,11 +342,10 @@ const stop = useCallback(() => {
</Info>
<Tip>
A [PR to the AI SDK](https://github.com/vercel/ai/pull/14350) has been
submitted to pass `abortSignal` through `reconnectToStream`, which would make
`useChat`'s built-in `stop()` work after resume without needing
`stopGeneration`. Until that lands, use the pattern above for reliable stop
behavior after page refresh.
Aborting a resumed stream never stops the run by itself, so `useChat`'s
built-in `stop()` isn't enough after a page refresh. Use the pattern above, or
pass `stopOnAbort: true` to `reconnectToStream` when that subscriber owns the
turn.
</Tip>
See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task.
+10
View File
@@ -1203,6 +1203,16 @@ paths:
description: Error message describing the query error
"401":
description: Unauthorized - API key is missing or invalid
"429":
description: Query service is busy or rate limited - retry shortly
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message describing why the query was turned away
"500":
description: Internal server error during query execution
tags:
@@ -11,3 +11,4 @@ export * from "./suggested-prompts.js";
export * from "./trigger-uri.js";
export * from "./watch.js";
export * from "./watch-wording.js";
export * from "./well-formed.js";
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { sliceWellFormed, toWellFormedDeep } from "./well-formed.js";
const emoji = "😀"; // one surrogate pair
describe("sliceWellFormed", () => {
it("drops the high surrogate when the cut splits a pair", () => {
expect(sliceWellFormed(`ab${emoji}`, 3)).toBe("ab");
});
it("keeps a pair that fits exactly", () => {
expect(sliceWellFormed(`ab${emoji}cd`, 4)).toBe(`ab${emoji}`);
});
it("leaves ascii alone", () => {
expect(sliceWellFormed("abcdef", 3)).toBe("abc");
});
it("is a no-op when the limit is at or past the length", () => {
expect(sliceWellFormed(`ab${emoji}`, 4)).toBe(`ab${emoji}`);
expect(sliceWellFormed(`ab${emoji}`, 99)).toBe(`ab${emoji}`);
});
it("keeps a lone surrogate that was already in the input", () => {
const lone = "ab\ud83d";
expect(sliceWellFormed(lone, 99)).toBe(lone);
expect(sliceWellFormed("a\ud83dbc", 3)).toBe("a\ud83db");
});
it("drops a pre-existing lone high surrogate that lands on the cut", () => {
expect(sliceWellFormed("ab\ud83dz", 3)).toBe("ab");
});
it("does not alter interior content", () => {
expect(sliceWellFormed(`${emoji}x${emoji}yz`, 5)).toBe(`${emoji}x${emoji}`);
});
});
describe("toWellFormedDeep", () => {
it("replaces a lone surrogate nested in a tool input", () => {
const message = {
id: "msg_1",
role: "assistant",
parts: [{ type: "tool-search", input: { query: "cat \ud83d", limit: 5 } }],
};
const result = toWellFormedDeep(message);
expect(result.parts[0].input.query).toBe("cat ");
expect(result.parts[0].input.limit).toBe(5);
expect(result).not.toBe(message);
});
it("returns the same reference when nothing changed", () => {
const message = { id: "msg_1", parts: [{ text: `hello ${emoji}` }], meta: null };
expect(toWellFormedDeep(message)).toBe(message);
});
it("replaces a lone surrogate in a key", () => {
const result: Record<string, unknown> = toWellFormedDeep({ "k\ud800": "v" });
expect(Object.keys(result)).toEqual(["k"]);
expect(result["k"]).toBe("v");
});
it("walks arrays", () => {
const messages = [{ text: "ok" }, { text: "\udc00bad" }];
const result = toWellFormedDeep(messages);
expect(result[1].text).toBe("bad");
expect(result[0]).toBe(messages[0]);
});
it("leaves non-string primitives and non-plain objects alone", () => {
const date = new Date(0);
const value = { n: 1, b: true, nil: null, u: undefined, date };
const result = toWellFormedDeep(value);
expect(result).toBe(value);
expect(result.date).toBe(date);
});
});
@@ -0,0 +1,53 @@
// `slice` that can't end on a high surrogate — split pair or already lone, dropped
// either way. A lone surrogate is invalid UTF-8 and jsonb rejects it.
export function sliceWellFormed(s: string, n: number): string {
if (n >= s.length) return s;
const cut = s.slice(0, n);
const last = cut.charCodeAt(cut.length - 1);
if (last >= 0xd800 && last <= 0xdbff) return cut.slice(0, -1);
return cut;
}
// `toWellFormed` is ES2024; this package targets ES2022.
interface WellFormable {
toWellFormed(): string;
}
function wellFormed(s: string): string {
return (s as unknown as WellFormable).toWellFormed();
}
function isPlainObject(value: object): boolean {
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Every string in a JSON-ish value made well-formed, keys included, so a lone surrogate
* anywhere tool input, filename, url can't reach jsonb. Anything unchanged is returned
* as it was, and anything with a custom prototype (a Date, a class instance) is untouched.
*/
export function toWellFormedDeep<T>(value: T): T {
if (typeof value === "string") return wellFormed(value) as T;
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item) => {
const fixed = toWellFormedDeep(item);
if (fixed !== item) changed = true;
return fixed;
});
return (changed ? next : value) as T;
}
if (value !== null && typeof value === "object" && isPlainObject(value)) {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const fixedKey = wellFormed(key);
const fixed = toWellFormedDeep(item);
if (fixed !== item || fixedKey !== key) changed = true;
next[fixedKey] = fixed;
}
return (changed ? next : value) as T;
}
return value;
}
@@ -0,0 +1,8 @@
CREATE TABLE "trigger_dashboard_agent"."agent_message_usage" (
"organization_id" text NOT NULL,
"period" text NOT NULL,
"count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_message_usage_organization_id_period_pk" PRIMARY KEY("organization_id","period")
);
@@ -0,0 +1,2 @@
ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "sweep_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "last_sweep_attempt_at" timestamp with time zone;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,20 @@
"when": 1786264383741,
"tag": "0003_backfill_chat_last_read_at",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1786359241538,
"tag": "0004_stale_corsair",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1786376934874,
"tag": "0005_ambitious_mordo",
"breakpoints": true
}
]
}
@@ -1,5 +1,6 @@
import {
investigationBlockSchema,
toWellFormedDeep,
VIEW_BLOCK_VERSION,
WATCH_REQUEST_MESSAGE_ID_PREFIX,
} from "@internal/dashboard-agent-contracts";
@@ -8,6 +9,7 @@ import type { DashboardAgentDb } from "./client.js";
import { generateInvestigationId } from "./ids.js";
import { lockChatForWatches, type DashboardAgentDbOrTx } from "./internal.js";
import {
agentMessageUsage,
chatMessages,
chats,
chatSessions,
@@ -126,6 +128,45 @@ export async function countUserMessages(
return rows[0]?.count ?? 0;
}
/**
* The message count for one org in one billing period. Reads the standalone counter,
* never the chat rows, so a deleted chat can't lower it within the period. `period` is
* a UTC calendar month, "YYYY-MM"; the caller chooses it.
*/
export async function getAgentMessageUsage(
db: DashboardAgentDb,
params: { organizationId: string; period: string }
): Promise<number> {
const rows = await db
.select({ count: agentMessageUsage.count })
.from(agentMessageUsage)
.where(
and(
eq(agentMessageUsage.organizationId, params.organizationId),
eq(agentMessageUsage.period, params.period)
)
)
.limit(1);
return rows[0]?.count ?? 0;
}
/** Bump the counter by one, creating the period row on first use. Returns the new count. */
export async function incrementAgentMessageUsage(
db: DashboardAgentDb,
params: { organizationId: string; period: string; by?: number }
): Promise<number> {
const by = params.by ?? 1;
const rows = await db
.insert(agentMessageUsage)
.values({ organizationId: params.organizationId, period: params.period, count: by })
.onConflictDoUpdate({
target: [agentMessageUsage.organizationId, agentMessageUsage.period],
set: { count: sql`${agentMessageUsage.count} + ${by}`, updatedAt: sql`now()` },
})
.returning({ count: agentMessageUsage.count });
return rows[0]?.count ?? by;
}
/**
* Chats whose transcript moved on after their owner last looked. A watch wake is one way
* that happens; an answer that landed while the panel was closed is another, and the panel
@@ -216,7 +257,7 @@ export async function createChat(
organizationId: params.organizationId,
userId: params.userId,
title: params.title ?? DEFAULT_CHAT_TITLE,
metadata: params.metadata ?? {},
metadata: toWellFormedDeep(params.metadata ?? {}),
})
.onConflictDoNothing();
}
@@ -406,8 +447,9 @@ async function storeChatMessages(
tx: DashboardAgentDbOrTx,
params: { chatId: string; messages: unknown[]; finalizable?: ReadonlySet<string> }
): Promise<void> {
// Every batch write lands here, so this is where a lone surrogate stops before jsonb.
const deduped = new Map<string, unknown>();
for (const message of params.messages) {
for (const message of toWellFormedDeep(params.messages)) {
const id = messageIdOf(params.chatId, message);
if (deduped.has(id)) {
throw new Error(`Chat ${params.chatId} was handed message id ${id} twice in one batch`);
@@ -528,7 +570,9 @@ async function appendOneMessage(
db: DashboardAgentDbOrTx,
params: { chatId: string; message: unknown; scope: SQL[] }
): Promise<boolean> {
const messageId = messageIdOf(params.chatId, params.message);
// Single-message appends land here — normalize like storeChatMessages.
const message = toWellFormedDeep(params.message);
const messageId = messageIdOf(params.chatId, message);
const rows = await db.execute<{ message_id: string }>(sql`
with reserved as (
update ${chats}
@@ -545,8 +589,8 @@ async function appendOneMessage(
returning "next_message_position" - 1 as "position"
)
insert into ${chatMessages} ("chat_id", "message_id", "position", "role", "message")
select ${params.chatId}, ${messageId}, reserved."position", ${messageRoleOf(params.chatId, params.message)},
${JSON.stringify(params.message)}::jsonb
select ${params.chatId}, ${messageId}, reserved."position", ${messageRoleOf(params.chatId, message)},
${JSON.stringify(message)}::jsonb
from reserved
on conflict ("chat_id", "message_id") do nothing
returning "message_id"
@@ -700,7 +744,7 @@ export async function persistTurn(
/** Idempotent on `(chatId, turn)`: a retried eval task can't write a second row. */
export async function insertTurnEval(db: DashboardAgentDb, row: NewChatTurnEval): Promise<void> {
await db.insert(chatTurnEvals).values(row).onConflictDoNothing();
await db.insert(chatTurnEvals).values(toWellFormedDeep(row)).onConflictDoNothing();
}
/**
@@ -824,6 +868,7 @@ export async function upsertInvestigationRevision(
state: unknown;
}
): Promise<UpsertInvestigationResult> {
const state = toWellFormedDeep(params.state);
if (!params.id) {
const id = generateInvestigationId();
await db.insert(investigations).values({
@@ -832,7 +877,7 @@ export async function upsertInvestigationRevision(
projectRef: params.projectRef,
environmentRef: params.environmentRef,
revision: 0,
state: params.state,
state,
});
return { ok: true, id, revision: 0, created: true };
}
@@ -840,7 +885,7 @@ export async function upsertInvestigationRevision(
const rows = await db
.update(investigations)
.set({
state: params.state,
state,
revision: sql`${investigations.revision} + 1`,
updatedAt: sql`now()`,
})
@@ -898,7 +943,7 @@ export async function seedInvestigation(
projectRef: params.projectRef,
environmentRef: params.environmentRef,
revision: 0,
state: params.state,
state: toWellFormedDeep(params.state),
})
.onConflictDoNothing({ target: investigations.id })
.returning({ id: investigations.id });
@@ -1045,6 +1090,10 @@ export async function listChatIdsWithOpenInvestigations(
/**
* Sweep for investigations nothing else settles. `olderThan` is on `updated_at`,
* which every revision bumps, so a card a live turn is writing to stays out.
*
* Order is `last_sweep_attempt_at` nulls first, then `updated_at`: a never-attempted
* row is always seen before one a prior sweep already failed on, so a row that can't
* settle rotates to the back instead of pinning the head and starving newer rows.
*/
export async function listStaleOpenInvestigations(
db: DashboardAgentDb,
@@ -1062,12 +1111,39 @@ export async function listStaleOpenInvestigations(
sql`${investigations.updatedAt} <= ${params.olderThan.toISOString()}::timestamptz`
)
)
.orderBy(investigations.updatedAt)
.orderBy(sql`${investigations.lastSweepAttemptAt} asc nulls first`, investigations.updatedAt)
.limit(params.limit ?? 100);
return rows.map((row) => row.investigation);
}
/**
* Record a failed stale-sweep settle on its own, committed outside the settle tx that
* rolled back. Bumps the attempt count and stamps `last_sweep_attempt_at` which does
* NOT touch `updated_at`, so the row still reads as stale, only later in the order.
* Returns the new count, or null when the row is no longer `in_progress`.
*/
export async function recordInvestigationSweepAttempt(
db: DashboardAgentDbOrTx,
params: { id: string }
): Promise<number | null> {
const rows = await db
.update(investigations)
.set({
sweepAttempts: sql`${investigations.sweepAttempts} + 1`,
lastSweepAttemptAt: sql`now()`,
})
.where(
and(
eq(investigations.id, params.id),
sql`${investigations.state}->>'outcome' = 'in_progress'`
)
)
.returning({ sweepAttempts: investigations.sweepAttempts });
return rows[0]?.sweepAttempts ?? null;
}
/** What the settle wrote, which is what the closing card has to render. */
export type SettledInvestigation = { id: string; revision: number; state: unknown };
@@ -170,6 +170,10 @@ export const investigations = dashboardAgentSchema.table(
// Monotonic; bumped by a single atomic UPDATE.
revision: integer("revision").notNull().default(0),
state: jsonb("state").$type<unknown>().notNull(),
// Failed stale-sweep settle attempts. Bumped outside the rolled-back settle tx so a
// row that can't render rotates to the back of the sweep order instead of pinning it.
sweepAttempts: integer("sweep_attempts").notNull().default(0),
lastSweepAttemptAt: timestamp("last_sweep_attempt_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
@@ -183,6 +187,23 @@ export const investigations = dashboardAgentSchema.table(
]
);
/**
* Per-(org, period) message counter. Deliberately not joined to chats: deleting a chat
* must not free quota inside the period. `period` is a UTC calendar month, "YYYY-MM".
* Org id is a main-DB id with no FK.
*/
export const agentMessageUsage = dashboardAgentSchema.table(
"agent_message_usage",
{
organizationId: text("organization_id").notNull(),
period: text("period").notNull(),
count: integer("count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.organizationId, t.period] })]
);
export type Chat = typeof chats.$inferSelect;
export type NewChat = typeof chats.$inferInsert;
export type ChatMessage = typeof chatMessages.$inferSelect;
@@ -193,3 +214,5 @@ export type ChatTurnEval = typeof chatTurnEvals.$inferSelect;
export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert;
export type Investigation = typeof investigations.$inferSelect;
export type NewInvestigation = typeof investigations.$inferInsert;
export type AgentMessageUsage = typeof agentMessageUsage.$inferSelect;
export type NewAgentMessageUsage = typeof agentMessageUsage.$inferInsert;
@@ -494,6 +494,22 @@ export async function countUnreadWatchWakes(
return rows[0]?.count ?? 0;
}
/**
* How many active watches an org has, across all its chats and users. The plan-limit floor
* is org-wide, so this is org-scoped only; a chat deletion cancels its watches, so `active`
* is the whole count.
*/
export async function countActiveWatchesForOrg(
db: DashboardAgentDb,
params: { organizationId: string }
): Promise<number> {
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(watches)
.where(and(eq(watches.status, "active"), eq(watches.organizationId, params.organizationId)));
return rows[0]?.count ?? 0;
}
/**
* Whether this user has a watch that can still wake them here. Covered by
* `watches_org_user_active_idx`; a chat deletion cancels its watches, so `active` is enough.
@@ -1,3 +1,4 @@
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
import { locals, logger } from "@trigger.dev/sdk";
import type { ChatAgentCompactionOptions, SummarizeEvent } from "@trigger.dev/sdk/ai";
import { generateText, type ModelMessage, type UIMessage } from "ai";
@@ -263,7 +264,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string {
.map((message) => {
const content =
typeof message.content === "string" ? message.content : JSON.stringify(message.content);
return `${message.role}: ${(content ?? "").slice(0, SUMMARY_INPUT_MESSAGE_CHARS)}`;
return `${message.role}: ${sliceWellFormed(content ?? "", SUMMARY_INPUT_MESSAGE_CHARS)}`;
})
.join("\n\n");
}
@@ -1,4 +1,4 @@
import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts";
import { isWatchRequestMessageId, sliceWellFormed } from "@internal/dashboard-agent-contracts";
import { chat } from "@trigger.dev/sdk/ai";
import { locals, logger, tasks } from "@trigger.dev/sdk";
import { generateText, stepCountIs, streamText, type ModelMessage, type UIMessage } from "ai";
@@ -181,7 +181,7 @@ export function truncateEvalToolValue(value: unknown, limit: number): unknown {
if (serialized === undefined || serialized.length <= limit) return value;
return {
truncated: true,
outputPrefix: serialized.slice(0, limit),
outputPrefix: sliceWellFormed(serialized, limit),
note: `[truncated: the first ${limit} of ${serialized.length} characters of this value]`,
};
}
@@ -267,12 +267,11 @@ export function extractToolActivity(
}
function cleanTitle(raw: string): string {
return raw
const normalized = raw
.trim()
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
.replace(/\s+/g, " ");
return sliceWellFormed(normalized, 80).trim();
}
/**
@@ -1,3 +1,4 @@
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
import { logger } from "@trigger.dev/sdk";
/**
@@ -353,7 +354,7 @@ const APPLICATION_ERROR = /\b[A-Z][A-Za-z0-9]*(Error|Exception)\b|\b(Error|Excep
* recognises is `unknown` rather than guessed into a bucket.
*/
export function classifyEvalError(output: unknown): EvalErrorCategory {
const signal = errorSignalText(output).slice(0, MAX_CLASSIFY_CHARS);
const signal = sliceWellFormed(errorSignalText(output), MAX_CLASSIFY_CHARS);
const haystack = signal.toLowerCase();
for (const [category, pattern] of CATEGORY_RULES) {
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
import { tool, type ToolSet } from "ai";
import {
getRepoInfoSchema,
@@ -313,7 +314,7 @@ export function buildRepoTools(
.map((line) => {
const m = line.match(/^([^:]+):(\d+):(.*)$/);
return m
? { file: m[1], line: Number(m[2]), text: m[3].slice(0, 300) }
? { file: m[1], line: Number(m[2]), text: sliceWellFormed(m[3], 300) }
: { text: line };
});
return { matches, truncated: matches.length >= cap };
@@ -42,11 +42,12 @@ const GET_TIMEOUT_MS = 10_000;
const JWT_TIMEOUT_MS = 10_000;
const QUERY_TIMEOUT_MS = 30_000;
// "query" is the server rejecting the TRQL, "transport" is the request breaking. Chart
// "query" is the server rejecting the TRQL, "transport" is the request breaking, "busy" is
// the server too loaded or rate limited to answer — the same query may work shortly. Chart
// validation only fails a render on "query".
export type QueryPostResult =
| { ok: true; rows: Array<Record<string, unknown>> }
| { ok: false; kind: "query" | "transport"; error: string };
| { ok: false; kind: "query" | "transport" | "busy"; error: string };
export const NO_AUTH = { error: "No delegated access is available for this turn." } as const;
@@ -215,6 +216,15 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
// The route returns 400 with { error } for invalid TRQL.
const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string };
if (!res.ok) {
// 429 is the concurrency rejection and the rate limiter: nothing is wrong with the
// query, so it is not a query error.
if (res.status === 429) {
return {
ok: false,
kind: "busy",
error: `${data.error ?? "The query service is busy right now."} You can retry the same query shortly.`,
};
}
return {
ok: false,
kind: res.status >= 500 ? "transport" : "query",
@@ -234,7 +244,7 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
): Promise<string | null> {
const result = await postQuery(query, period);
if (isEnvUnavailable(result) || result.ok) return null;
if (result.kind === "transport") {
if (result.kind === "transport" || result.kind === "busy") {
logger.warn("Skipped chart query validation", { error: result.error });
return null;
}
@@ -88,6 +88,45 @@ describe("a broken request reads as a broken request, never as an answer", () =>
expect(JSON.stringify(result)).not.toContain("isn't locked to a deployed version");
});
it("classifies a busy query route as busy, not as a bad query", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/jwt")
? Response.json({ token: "jwt" })
: Response.json(
{ error: "We're experiencing a lot of queries at the moment." },
{ status: 429 }
)
)
);
const result = await createApiClient(CTX).postQuery("SELECT 1", undefined);
expect(result).toMatchObject({ ok: false, kind: "busy" });
expect((result as { error: string }).error).toContain("retry the same query shortly");
});
// The other half of the same invariant: only 429 is busy, so a rejected query still counts.
it("classifies a rejected query as a query error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/jwt")
? Response.json({ token: "jwt" })
: Response.json({ error: "Unknown expression identifier 'createdAt'." }, { status: 400 })
)
);
const result = await createApiClient(CTX).postQuery("SELECT createdAt FROM runs", undefined);
expect(result).toMatchObject({
ok: false,
kind: "query",
error: "Unknown expression identifier 'createdAt'.",
});
});
it("still reports a real 404 as the answer it is", async () => {
vi.stubGlobal(
"fetch",
@@ -186,6 +186,9 @@ export function withLiveState(metrics: unknown, queueType: "task" | "custom", li
};
}
/** Failed `run_query` calls in a row before the tool tells the model to stop and answer. */
export const MAX_CONSECUTIVE_QUERY_FAILURES = 3;
export function buildApiTools(args: {
ctx: DashboardAgentToolContext;
client: DashboardAgentApiClient;
@@ -195,6 +198,12 @@ export function buildApiTools(args: {
const { userActorToken, projectRef, environmentName, environmentBranch } = ctx;
const { origin, hasAuth, envApiGet, postQuery, validateChartQuery } = client;
// A failed query hands the model the database error to fix, and it usually does. When it
// doesn't, the only other limit is the turn's 10 steps, so one broken query can eat the
// whole turn and leave the user with no answer at all. This tool set is built per turn,
// so the counter caps consecutive failures within one turn.
let consecutiveQueryFailures = 0;
return {
list_projects: tool({
...listProjectsSchema,
@@ -350,7 +359,20 @@ export function buildApiTools(args: {
execute: async ({ query, period }) => {
const result = await postQuery(query, period);
if (isEnvUnavailable(result)) return envUnavailableError(result, "query");
if (!result.ok) return { error: result.error };
if (!result.ok) {
// Only SQL errors count toward the cap; transport and busy errors are transient,
// and the same query may work on a retry.
if (result.kind === "query") {
consecutiveQueryFailures++;
if (consecutiveQueryFailures >= MAX_CONSECUTIVE_QUERY_FAILURES) {
return {
error: `${result.error} That is ${consecutiveQueryFailures} queries in a row that failed. Stop querying and answer the user with what you already have.`,
};
}
}
return { error: result.error };
}
consecutiveQueryFailures = 0;
const cap = 200;
const rows = result.rows;
return { rows: rows.slice(0, cap), rowCount: rows.length, truncated: rows.length > cap };
@@ -1,4 +1,5 @@
import type { JSONValue } from "@ai-sdk/provider";
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
/**
* Everything that trims an API payload down to what a tool returns, plus the two
@@ -18,7 +19,7 @@ export function fenceUntrusted(label: string, text: unknown): string | undefined
const raw = String(text).replaceAll("«", "<").replaceAll("»", ">");
const capped =
raw.length > MAX_UNTRUSTED_FIELD_CHARS
? `${raw.slice(0, MAX_UNTRUSTED_FIELD_CHARS)}…[truncated ${
? `${sliceWellFormed(raw, MAX_UNTRUSTED_FIELD_CHARS)}…[truncated ${
raw.length - MAX_UNTRUSTED_FIELD_CHARS
} chars]`
: raw;
@@ -1,3 +1,5 @@
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
// A JSON-RPC `tools/call` against the public docs MCP endpoint: no auth, no user data.
// The endpoint answers with either JSON or a single-event SSE stream.
const DOCS_MCP_URL = "https://trigger.dev/docs/mcp";
@@ -44,7 +46,7 @@ export function formatDocsResults(parts: string[]): string {
const excerpt =
body.length > DOC_EXCERPT_MAX_CHARS
? `${body.slice(0, DOC_EXCERPT_MAX_CHARS).trimEnd()}… [excerpt — the rest is on the page]`
? `${sliceWellFormed(body, DOC_EXCERPT_MAX_CHARS).trimEnd()}… [excerpt — the rest is on the page]`
: body;
const entry = [
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import { buildApiTools, MAX_CONSECUTIVE_QUERY_FAILURES } from "./tool-api";
import type { DashboardAgentApiClient } from "./tool-api-client";
/**
* A failed query hands the model the database error to fix. Without a cap, the only other
* limit is the turn's step budget, so a model that keeps rewriting the same broken query
* burns the whole turn and the user gets no answer. After three failures in a row the tool
* tells it to stop and answer.
*/
function queryTool(postQuery: DashboardAgentApiClient["postQuery"]) {
const client = {
origin: "https://api.example.com",
hasAuth: true,
envApiGet: async () => ({ ok: false as const, status: 500 }),
postQuery,
validateChartQuery: async () => null,
} as unknown as DashboardAgentApiClient;
const tools = buildApiTools({
ctx: { userActorToken: "uat", apiOrigin: client.origin },
client,
renderInvestigations: (() => []) as any,
});
return (query: string) => (tools.run_query as any).execute({ query }, {} as any);
}
const failure = {
ok: false as const,
kind: "query" as const,
error: "Unknown expression identifier 'createdAt'.",
};
const transportFailure = {
ok: false as const,
kind: "transport" as const,
error: "The environment is temporarily unavailable.",
};
const busyFailure = {
ok: false as const,
kind: "busy" as const,
error: "We're experiencing a lot of queries at the moment. You can retry the same query shortly.",
};
const success = { ok: true as const, rows: [{ n: 1 }] };
describe("run_query's consecutive-failure cap", () => {
it("keeps handing back the plain error until the cap", async () => {
const run = queryTool(async () => failure);
for (let attempt = 1; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) {
const result = await run("SELECT createdAt FROM runs");
expect(result.error).toBe(failure.error);
}
});
it("tells the model to stop and answer at the cap", async () => {
const run = queryTool(async () => failure);
let result: { error: string } = { error: "" };
for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) {
result = await run("SELECT createdAt FROM runs");
}
expect(result.error).toContain(failure.error);
expect(result.error).toContain("answer the user with what you already have");
});
it("counts consecutive failures only, so a good query clears the count", async () => {
const postQuery = vi
.fn()
.mockResolvedValueOnce(failure)
.mockResolvedValueOnce(failure)
.mockResolvedValueOnce(success)
.mockResolvedValue(failure);
const run = queryTool(postQuery as any);
await run("bad");
await run("bad");
await run("good");
const result = await run("bad");
expect(result.error).toBe(failure.error);
});
it("does not count transport errors toward the cap", async () => {
const run = queryTool(async () => transportFailure);
let result: { error: string } = { error: "" };
for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES + 2; attempt++) {
result = await run("SELECT createdAt FROM runs");
}
expect(result.error).toBe(transportFailure.error);
expect(result.error).not.toContain("answer the user with what you already have");
});
// A "too busy" rejection says nothing about the query, so spending the cap on it would
// stop the model over a queue that clears in seconds.
it("does not count busy rejections toward the cap", async () => {
const run = queryTool(async () => busyFailure);
let result: { error: string } = { error: "" };
for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES + 2; attempt++) {
result = await run("SELECT createdAt FROM runs");
}
expect(result.error).toBe(busyFailure.error);
expect(result.error).not.toContain("answer the user with what you already have");
});
it("still caps real SQL errors that follow busy rejections", async () => {
const postQuery = vi.fn().mockResolvedValueOnce(busyFailure).mockResolvedValue(failure);
const run = queryTool(postQuery as any);
await run("busy");
let result: { error: string } = { error: "" };
for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) {
result = await run("SELECT createdAt FROM runs");
}
expect(result.error).toContain("answer the user with what you already have");
});
});
+528 -4
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { UIMessage, UIMessageChunk } from "ai";
import { TriggerChatTransport, createChatTransport } from "./chat.js";
import { TriggerChatTransport, createChatTransport, type ChatTransportEvent } from "./chat.js";
// ───────────────────────────────────────────────────────────────────────────
// Test helpers
@@ -132,6 +132,35 @@ function defaultSseResponse(
});
}
/**
* An SSE response whose body stays open until the request signal aborts.
* Models a live subscription sitting on a quiet server.
*/
function openSseResponse(signal?: AbortSignal | null): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
const onAbort = () => {
const err = new Error("aborted");
err.name = "AbortError";
try {
controller.error(err);
} catch {
/* already errored */
}
};
if (signal?.aborted) onAbort();
else signal?.addEventListener("abort", onAbort, { once: true });
},
});
return new Response(body, {
status: 200,
headers: {
"content-type": "text/event-stream",
"X-Stream-Version": "v2",
},
});
}
function authError(status = 401): Response {
return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), {
status,
@@ -1027,6 +1056,37 @@ describe("TriggerChatTransport", () => {
expect(result).toBeNull();
});
it("resumes in watch mode when the session is hydrated with isStreaming=false", async () => {
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
const response = defaultSseResponse([{ type: "text-delta", id: "p1", delta: "turn2" }]);
const headers = new Headers(response.headers);
headers.set("X-Session-Settled", "true");
return new Response(response.body, { status: 200, headers });
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: {
"chat-rc-watch": { publicAccessToken: "p", isStreaming: false },
},
});
const stream = await transport.reconnectToStream({ chatId: "chat-rc-watch" });
expect(stream).not.toBeNull();
const chunks = await drainChunks(stream!);
expect(subscribeCount).toBe(1);
expect(chunks).toEqual([{ type: "text-delta", id: "p1", delta: "turn2" }]);
});
it("opens an SSE subscription with the X-Peek-Settled header set", async () => {
let subscribeHeaders: Headers | undefined;
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
@@ -1176,7 +1236,7 @@ describe("TriggerChatTransport", () => {
expect(transport.getSession("chat-slow")?.isStreaming).toBe(false);
});
it("gives up after a bounded number of resubscribes", async () => {
it("surfaces an error after the resubscribe budget is exhausted", async () => {
// Fake timers so the 100ms..1.6s backoffs don't cost real seconds.
vi.useFakeTimers();
try {
@@ -1205,12 +1265,18 @@ describe("TriggerChatTransport", () => {
messages: [createUserMessage("hi")],
abortSignal: undefined,
});
// A cut-off turn surfaces an error rather than reading as complete.
// Attach the rejection assertion before advancing timers so the
// rejection is never unhandled.
const drained = drainChunks(stream);
const rejects = expect(drained).rejects.toThrow(/reconnect budget exhausted/i);
await vi.advanceTimersByTimeAsync(10_000);
await drained;
await rejects;
// One initial connect plus the five-attempt resubscribe budget.
expect(subscribeCount).toBe(6);
// State is cleared before the throw, so a reload won't reopen a
// doomed subscription.
expect(transport.getSession("chat-empty")?.isStreaming).toBe(false);
} finally {
vi.useRealTimers();
@@ -1218,6 +1284,455 @@ describe("TriggerChatTransport", () => {
});
});
describe("watch mode across long-poll window boundaries", () => {
function settled(response: Response): Response {
const headers = new Headers(response.headers);
headers.set("X-Session-Settled", "true");
return new Response(response.body, { status: 200, headers });
}
it("resubscribes after a completed turn and receives a later wake", async () => {
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
// Window 1: a turn completes, then the body EOFs with no
// settled header — the quiet long-poll boundary.
return subscribeCount === 1
? defaultSseResponse([
{ type: "text-delta", id: "p1", delta: "turn1" },
{ type: "trigger:turn-complete" },
])
: settled(defaultSseResponse([{ type: "text-delta", id: "p2", delta: "wake" }]));
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-watch-eof": { publicAccessToken: "p", isStreaming: true } },
});
const stream = await transport.reconnectToStream({ chatId: "chat-watch-eof" });
const chunks = await drainChunks(stream!);
expect(subscribeCount).toBe(2);
expect(chunks).toEqual([
{ type: "text-delta", id: "p1", delta: "turn1" },
{ type: "text-delta", id: "p2", delta: "wake" },
]);
});
it("does not peek-settle an idle resubscribe, so the next turn is delivered", async () => {
// Watch mode must NOT send X-Peek-Settled between turns: a settled peek
// while no turn is in flight closes the standing subscription and the
// viewer never sees turn 2. This mock plays the server's peek shortcut —
// a peek request with nothing in flight settles — to prove the transport
// long-polls instead.
const subscribeHeaders: Headers[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeHeaders.push(new Headers(init?.headers));
const n = subscribeHeaders.length;
if (n === 1) {
// Turn 1 completes, then the body EOFs (no settled header).
return defaultSseResponse([
{ type: "text-delta", id: "p1", delta: "turn1" },
{ type: "trigger:turn-complete" },
]);
}
if (n === 2) {
// Idle resubscribe. If it peeked, the server settles and the
// subscription would close before turn 2; a long-poll delivers it.
if (init && new Headers(init.headers).get("X-Peek-Settled")) {
return settled(defaultSseResponse([]));
}
return defaultSseResponse([
{ type: "text-delta", id: "p2", delta: "turn2" },
{ type: "trigger:turn-complete" },
]);
}
// Turn 2 done — end the watch cleanly.
return settled(defaultSseResponse([]));
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-watch-turn2": { publicAccessToken: "p", isStreaming: true } },
});
const stream = await transport.reconnectToStream({ chatId: "chat-watch-turn2" });
const chunks = await drainChunks(stream!);
expect(subscribeHeaders[1]?.get("X-Peek-Settled")).toBeNull();
expect(chunks).toEqual([
{ type: "text-delta", id: "p1", delta: "turn1" },
{ type: "text-delta", id: "p2", delta: "turn2" },
]);
});
it("cancelling the reader stops the resubscribe loop", async () => {
// A consumer that stops reading without aborting must not leak the
// resubscribe loop — the stream's cancel() aborts it.
vi.useFakeTimers();
try {
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
// Quiet: EOF, no records, never settled — watch keeps resubscribing.
return defaultSseResponse([]);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const events: ChatTransportEvent[] = [];
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
onEvent: (e) => events.push(e),
sessions: { "chat-watch-cancel": { publicAccessToken: "p", isStreaming: true } },
});
const stream = await transport.reconnectToStream({ chatId: "chat-watch-cancel" });
const reader = stream!.getReader();
await vi.advanceTimersByTimeAsync(10_000);
expect(subscribeCount).toBeGreaterThan(1);
const countAtCancel = subscribeCount;
await reader.cancel();
await vi.advanceTimersByTimeAsync(10_000);
expect(subscribeCount).toBe(countAtCancel);
// A clean cancel must not surface a spurious stream-error (an
// unguarded controller.close() after cancel would throw "Invalid
// state" and leak it onto the telemetry channel).
expect(events.some((e) => e.type === "stream-error")).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("stops when the server says the session settled", async () => {
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
return settled(
defaultSseResponse([
{ type: "text-delta", id: "p1", delta: "last" },
{ type: "trigger:turn-complete" },
])
);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-watch-settled": { publicAccessToken: "p", isStreaming: true } },
});
const stream = await transport.reconnectToStream({ chatId: "chat-watch-settled" });
const chunks = await drainChunks(stream!);
expect(subscribeCount).toBe(1);
expect(chunks).toHaveLength(1);
expect(transport.getSession("chat-watch-settled")?.isStreaming).toBe(false);
});
it("stops promptly when aborted during backoff", async () => {
vi.useFakeTimers();
try {
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
// Every window is quiet: EOF with no records, never settled.
return defaultSseResponse([]);
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
const abortController = new AbortController();
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-watch-abort": { publicAccessToken: "p", isStreaming: true } },
});
const stream = await transport.reconnectToStream({
chatId: "chat-watch-abort",
abortSignal: abortController.signal,
});
const drained = drainChunks(stream!);
await vi.advanceTimersByTimeAsync(10_000);
// The budget doesn't apply in watch mode, so it is still reconnecting.
expect(subscribeCount).toBeGreaterThan(6);
const countAtAbort = subscribeCount;
abortController.abort();
await drained;
await vi.advanceTimersByTimeAsync(10_000);
expect(subscribeCount).toBe(countAtAbort);
} finally {
vi.useRealTimers();
}
});
});
describe("reconnectToStream stop-on-abort ownership (TRI-13070)", () => {
// A quiet stream: EOF, no records, never settled — the subscription
// stays alive (watch mode) so an abort mid-flight exercises the stop path.
function quietWatchTransport(): {
transport: TriggerChatTransport;
appends: () => number;
} {
let appendCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionStreamAppendUrl(urlStr)) {
appendCount++;
return defaultAppendResponse();
}
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]);
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-own": { publicAccessToken: "p", isStreaming: true } },
});
return { transport, appends: () => appendCount };
}
it("passive subscriber aborting writes no stop chunk to .in", async () => {
vi.useFakeTimers();
try {
const { transport, appends } = quietWatchTransport();
const abort = new AbortController();
const stream = await transport.reconnectToStream({
chatId: "chat-own",
abortSignal: abort.signal,
});
const drained = drainChunks(stream!);
await vi.advanceTimersByTimeAsync(1_000);
abort.abort();
await drained;
await vi.advanceTimersByTimeAsync(1_000);
expect(appends()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("owning subscriber with stopOnAbort:true sends a stop chunk on abort", async () => {
vi.useFakeTimers();
try {
const { transport, appends } = quietWatchTransport();
const abort = new AbortController();
const stream = await transport.reconnectToStream({
chatId: "chat-own",
abortSignal: abort.signal,
stopOnAbort: true,
});
const drained = drainChunks(stream!);
await vi.advanceTimersByTimeAsync(1_000);
abort.abort();
await drained;
await vi.advanceTimersByTimeAsync(1_000);
expect(appends()).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("abortSignal presence alone (stopOnAbort unset) sends no stop", async () => {
vi.useFakeTimers();
try {
const { transport, appends } = quietWatchTransport();
const abort = new AbortController();
const stream = await transport.reconnectToStream({
chatId: "chat-own",
abortSignal: abort.signal,
});
const drained = drainChunks(stream!);
await vi.advanceTimersByTimeAsync(1_000);
abort.abort();
await drained;
await vi.advanceTimersByTimeAsync(1_000);
expect(appends()).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
describe("superseded stream teardown", () => {
it("keeps the successor's controller registered when the aborted stream tears down", async () => {
vi.useFakeTimers();
try {
let appendCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionStreamAppendUrl(urlStr)) {
appendCount++;
return defaultAppendResponse();
}
// Quiet stream: EOF, no records, never settled — watch keeps it open.
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]);
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
watch: true,
sessions: { "chat-race": { publicAccessToken: "p", isStreaming: true } },
});
const send = () =>
transport.sendMessages({
trigger: "submit-message" as const,
chatId: "chat-race",
messageId: undefined,
messages: [createUserMessage("hi")],
abortSignal: undefined,
});
const first = drainChunks(await send());
await vi.advanceTimersByTimeAsync(1_000);
// Supersede: the new stream registers its controller synchronously,
// the aborted one tears down a microtask later.
const second = await send();
let secondClosed = false;
const secondDrain = drainChunks(second).then(() => {
secondClosed = true;
});
await first;
await vi.advanceTimersByTimeAsync(1_000);
// stopGeneration posts the stop chunk either way — only the
// closing assertion proves it found the successor to abort.
appendCount = 0;
expect(await transport.stopGeneration("chat-race")).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(appendCount).toBe(1);
expect(secondClosed).toBe(true);
transport.dispose();
await secondDrain;
} finally {
vi.useRealTimers();
}
});
it("keeps the tab claim the successor took (multi-tab)", async () => {
vi.useFakeTimers();
try {
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
// Open SSE that only ends when the subscription is aborted, so
// the superseded stream tears down while the successor is live.
if (isSessionOutSubscribeUrl(urlStr)) return openSseResponse(init?.signal);
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
multiTab: true,
sessions: { "chat-race-tab": { publicAccessToken: "p", isStreaming: true } },
});
const send = () =>
transport.sendMessages({
trigger: "submit-message" as const,
chatId: "chat-race-tab",
messageId: undefined,
messages: [createUserMessage("hi")],
abortSignal: undefined,
});
const first = drainChunks(await send());
await vi.advanceTimersByTimeAsync(1_000);
const secondDrain = drainChunks(await send());
await first;
await vi.advanceTimersByTimeAsync(1_000);
// The superseded stream must not release the claim its successor
// holds — otherwise this tab flips to read-only mid-turn.
expect(transport.hasClaim("chat-race-tab")).toBe(true);
transport.dispose();
await secondDrain;
} finally {
vi.useRealTimers();
}
});
it("releases the tab claim when the user stops generation (multi-tab)", async () => {
vi.useFakeTimers();
try {
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
if (isSessionOutSubscribeUrl(urlStr)) return openSseResponse(init?.signal);
throw new Error(`Unexpected URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-chat-task",
accessToken: () => "pat",
multiTab: true,
sessions: { "chat-stop-tab": { publicAccessToken: "p", isStreaming: true } },
});
const drain = drainChunks(
await transport.sendMessages({
trigger: "submit-message" as const,
chatId: "chat-stop-tab",
messageId: undefined,
messages: [createUserMessage("hi")],
abortSignal: undefined,
})
);
await vi.advanceTimersByTimeAsync(1_000);
expect(transport.hasClaim("chat-stop-tab")).toBe(true);
expect(await transport.stopGeneration("chat-stop-tab")).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
// The turn ends here with no successor stream, so the claim must be
// freed or other tabs stay read-only until this one closes.
expect(transport.hasClaim("chat-stop-tab")).toBe(false);
transport.dispose();
await drain;
} finally {
vi.useRealTimers();
}
});
});
describe("multi-tab coordination", () => {
it("isReadOnly defaults to false when multiTab is disabled", () => {
const transport = new TriggerChatTransport({
@@ -1488,9 +2003,18 @@ describe("TriggerChatTransport", () => {
{ type: "text-delta", id: "p2", delta: "Again" },
{ type: "trigger:turn-complete" },
];
let subscribeCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(turn1);
if (isSessionOutSubscribeUrl(urlStr)) {
subscribeCount++;
if (subscribeCount === 1) return defaultSseResponse(turn1);
// Watch mode reconnects past the body EOF; settle so the drain ends.
const response = defaultSseResponse([]);
const headers = new Headers(response.headers);
headers.set("X-Session-Settled", "true");
return new Response(response.body, { status: 200, headers });
}
throw new Error(`Unexpected URL: ${urlStr}`);
});
+85 -15
View File
@@ -873,7 +873,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
state.isStreaming = true;
this.notifySessionChange(chatId, state);
return this.subscribeToSessionStream(state, abortSignal, chatId, { sinceInSeq: inSeq });
// Owning turn: aborting this live send stops the turn the user drives.
return this.subscribeToSessionStream(state, abortSignal, chatId, {
sinceInSeq: inSeq,
sendStopOnAbort: true,
});
};
/**
@@ -1146,12 +1150,21 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
options: {
chatId: string;
abortSignal?: AbortSignal | undefined;
/**
* Whether aborting this subscription sends `{kind:"stop"}` on `.in`.
* A subscription ending is not session ownership a passive/watch
* reader unmounting must never stop a turn it doesn't drive. Only
* pass `true` from a caller that owns the live turn. @default false
*/
stopOnAbort?: boolean;
} & ChatRequestOptions
): Promise<ReadableStream<UIMessageChunk> | null> => {
const state = this.sessions.get(options.chatId);
if (!state) return null;
if (state.isStreaming === false) return null;
// Watch is a standing subscription: a settled session is exactly the
// state it waits in, so a completed last turn must not block the resume.
if (state.isStreaming === false && !this.watchMode) return null;
if (this.activeStreams.has(options.chatId)) return null;
const abortController = new AbortController();
@@ -1163,12 +1176,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
resumed: true,
sendStopOnAbort: !!options.abortSignal,
sendStopOnAbort: options.stopOnAbort ?? false,
// Reconnect-on-reload opts into the server's settled-peek shortcut
// so the SSE doesn't hang for 60s when no turn is in flight. Active
// send-a-message paths must keep wait=60 to avoid racing the
// freshly-triggered turn's first chunk.
peekSettled: true,
// freshly-triggered turn's first chunk. Watch mode must NOT peek: a
// settled peek between turns sets sessionSettled and closes the
// standing subscription, so the viewer never sees the next turn.
peekSettled: !this.watchMode,
});
};
@@ -1205,6 +1220,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
activeStream.abort();
this.activeStreams.delete(chatId);
}
// Release here, not in the stream teardown: that only releases while it
// still owns the map entry, and we just deleted it. Unlike a supersede,
// no successor stream follows a stop, so the claim would never be freed
// and other tabs would stay read-only until this one closes.
this.coordinator?.release(chatId);
// The turn won't reach its turn-complete on this client (we just
// aborted the reader), so clear the streaming flag here and persist —
@@ -1266,7 +1286,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
state.isStreaming = true;
this.notifySessionChange(chatId, state);
return this.subscribeToSessionStream(state, undefined, chatId, { sinceInSeq: inSeq });
// Owning action: aborting this send stops the turn the user drives.
return this.subscribeToSessionStream(state, undefined, chatId, {
sinceInSeq: inSeq,
sendStopOnAbort: true,
});
};
// -------------------------------------------------------------------------
@@ -1780,11 +1804,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
let eofResubscribes = 0;
const resumeAfterEof = async () => {
// Watch mode is a standing subscription: it outlives turn-complete
// (which clears `isStreaming`) and idle windows EOF by design, so the
// give-up budget doesn't apply. Only abort or a settled session ends it.
while (
state.isStreaming &&
(this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) &&
!currentSubscription?.sessionSettled &&
!combinedSignal.aborted &&
eofResubscribes < MAX_EOF_RESUBSCRIBES
!combinedSignal.aborted
) {
eofResubscribes++;
// Sleep, but wake immediately on abort — otherwise a stop lands
@@ -1796,7 +1822,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
combinedSignal.removeEventListener("abort", done);
resolve();
};
timer = setTimeout(done, Math.min(100 * 2 ** (eofResubscribes - 1), 5_000));
// Jitter the backoff so many clients reconnecting after the same
// dropped window don't resubscribe in lockstep.
const backoff = Math.min(100 * 2 ** (eofResubscribes - 1), 5_000);
timer = setTimeout(done, backoff * (0.5 + Math.random() * 0.5));
combinedSignal.addEventListener("abort", done);
});
if (combinedSignal.aborted) break;
@@ -1804,6 +1833,25 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
if (opened) return opened;
}
// A settled session or an abort ends the turn cleanly. Exhausting the
// resubscribe budget while the turn is still streaming means it was cut
// off — surface an error so the UI doesn't read a truncated reply as
// complete. The caller's catch emits stream-error and errors the stream.
if (
state.isStreaming &&
!currentSubscription?.sessionSettled &&
!combinedSignal.aborted
) {
// Clear + persist before throwing so the surfaced error leaves
// consistent state — otherwise a reload sees isStreaming: true
// and reopens a doomed subscription.
state.isStreaming = false;
this.notifySessionChange(chatId, state);
throw new Error(
"Chat stream ended before the turn completed (reconnect budget exhausted)."
);
}
// Settled close, or the turn is gone — tell the UI instead of
// leaving it spinning on a stream nobody will finish.
if (state.isStreaming) {
@@ -1823,7 +1871,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
const opened = (await openWithAuthRetry()) ?? (await resumeAfterEof());
if (opened === null) {
controller.close();
try {
controller.close();
} catch {
/* already closed by a consumer cancel */
}
return;
}
reader = opened.reader;
@@ -1853,7 +1905,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
if (next.done) {
const resumed = await resumeAfterEof();
if (resumed === null) {
controller.close();
try {
controller.close();
} catch {
/* already closed by a consumer cancel */
}
return;
}
reader = resumed.reader;
@@ -1866,7 +1922,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
if (combinedSignal.aborted) {
internalAbort.abort();
await reader.cancel();
controller.close();
try {
controller.close();
} catch {
/* already closed by a consumer cancel */
}
return;
}
@@ -2001,10 +2061,20 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
controller.error(error);
} finally {
teardownWakeListeners();
this.activeStreams.delete(chatId);
this.coordinator?.release(chatId);
// Only clear the entry (and drop the tab claim) if it is still
// ours — a superseding send registers its controller before this
// teardown runs, and owns the claim from then on.
if (this.activeStreams.get(chatId) === internalAbort) {
this.activeStreams.delete(chatId);
this.coordinator?.release(chatId);
}
}
},
// A consumer that stops reading without aborting (drops the reader)
// would otherwise leave the resubscribe loop running forever.
cancel() {
internalAbort.abort();
},
});
}
}
@@ -211,11 +211,20 @@ describe("transport stream events", () => {
``,
].join("\n");
let subscribes = 0;
const { transport, events } = makeTransport({
watch: true,
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } },
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS),
fetch: async (_url, _init, ctx) => {
if (ctx.endpoint === "in") return jsonOk();
if (subscribes++ > 0) {
// Watch mode reconnects past the body EOF; settle so the read ends.
const settled = sseResponse("");
settled.headers.set("X-Session-Settled", "true");
return settled;
}
return sseResponse(TWO_TURNS);
},
});
const stream = await transport.reconnectToStream({ chatId: "c1" });