Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
27b04fb
fix(sdk): watch-mode chat subscriptions survive quiet windows (TRI-13…
kathiekiwi Aug 10, 2026
574a84d
fix(sdk): stopping a turn is an owning-transport action, not an abort…
kathiekiwi Aug 10, 2026
8aa997b
Merge remote-tracking branch 'origin/feat/agent-storybook-gallery' in…
kathiekiwi Aug 10, 2026
e4cd316
Merge remote-tracking branch 'origin/feat/agent-storybook-gallery' in…
kathiekiwi Aug 10, 2026
0264812
merge: propagate review fixes from feat/agent-storybook-gallery
kathiekiwi Aug 10, 2026
fef7b43
fix(sdk): surface an error when a watch-mode turn is truncated by rec…
kathiekiwi Aug 10, 2026
a95973c
fix(sdk): jitter the chat reconnect backoff
kathiekiwi Aug 10, 2026
8feedd2
merge: propagate wave-2 review fixes from feat/agent-storybook-gallery
kathiekiwi Aug 10, 2026
22969ff
merge: propagate org-purge best-effort from feat/agent-storybook-gallery
kathiekiwi Aug 10, 2026
bdf01b0
fix(sdk): keep watch-mode chat streams open across turns and clear st…
kathiekiwi Aug 10, 2026
e5ede6f
test(sdk): cover watch-mode keepalive, reader-cancel, and budget-exha…
kathiekiwi Aug 10, 2026
c40e5f0
fix(sdk): guard controller.close() so a clean reader cancel emits no …
kathiekiwi Aug 10, 2026
100eb36
merge: watch keepalive settle/cancel guards review-comment fixes
kathiekiwi Aug 10, 2026
8c72806
merge: propagate review-comment fixes from feat/agent-storybook-gallery
kathiekiwi Aug 10, 2026
24a978c
merge: propagate second-pass fixes from feat/agent-storybook-gallery
kathiekiwi Aug 11, 2026
cdc7b5f
merge: propagate server-changes consolidation from feat/agent-storybo…
kathiekiwi Aug 11, 2026
d9dc61e
chore(changeset): consolidate the chat keepalive changesets into one
kathiekiwi Aug 11, 2026
e394f20
merge: consolidate the two chat changesets into one
kathiekiwi Aug 11, 2026
66eb3da
merge: propagate changeset consolidation and note restoration from fe…
kathiekiwi Aug 11, 2026
dfeffd8
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
47983a6
merge: propagate the tsql linter test fix from feat/agent-storybook-g…
kathiekiwi Aug 11, 2026
99f83d8
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
c1d851a
chore: merge feat/agent-storybook-gallery (main sync)
kathiekiwi Aug 11, 2026
5810c80
chore: merge feat/agent-storybook-gallery (review fixes)
kathiekiwi Aug 11, 2026
b1814dc
fix(sdk): keep the successor stream's abort controller when an aborte…
kathiekiwi Aug 11, 2026
ae1a936
chore: merge feat/agent-storybook-gallery (review fixes round 2)
kathiekiwi Aug 11, 2026
a026776
fix(sdk): release the tab claim when the user stops generation
kathiekiwi Aug 11, 2026
e24e584
chore: merge feat/agent-storybook-gallery (review fixes round 3)
kathiekiwi Aug 11, 2026
7f0b9a4
chore: merge feat/agent-storybook-gallery (review fixes round 3)
kathiekiwi Aug 11, 2026
1fa556f
chore: merge feat/agent-storybook-gallery (composer escape follow-up)
kathiekiwi Aug 11, 2026
aad20c3
chore: merge feat/agent-storybook-gallery (main sync post-#4529)
kathiekiwi Aug 12, 2026
a8ee578
chore: merge feat/agent-storybook-gallery (review fixes)
kathiekiwi Aug 12, 2026
8f3116a
chore: merge feat/agent-storybook-gallery (prompt re-send guard)
kathiekiwi Aug 12, 2026
0a5d7a9
chore: merge feat/agent-storybook-gallery (main sync post-#4525)
kathiekiwi Aug 12, 2026
550b717
chore: merge feat/agent-storybook-gallery (demo line-0 guard)
kathiekiwi Aug 12, 2026
e2948f1
feat(webapp): query boundary pinned end-to-end and a capped query ret…
kathiekiwi Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chat-stream-supersede-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Fixed a race where quickly restarting a chat stream could break stop and reconnect for the new stream. Stopping a chat now also hands it back to your other tabs instead of leaving them read-only.
5 changes: 5 additions & 0 deletions .changeset/watch-mode-keepalive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Watch-mode chat streams now survive quiet windows and keep delivering later turns, and a reply cut off by a lost connection now shows an error instead of appearing finished.
Comment on lines +1 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pull request bundles several unrelated features and fixes instead of one change

The change set mixes many unrelated pieces of work (the chat stream fix in packages/trigger-sdk/src/v3/chat.ts:1804-1852) with a new monthly message allowance, watch plan limits, query read-only enforcement, a query retry cap and an investigation sweep backstop, so the repository's one-change-per-request rule is broken.
Impact: Reviewers cannot evaluate or revert the stated fix independently of several unrelated behaviour changes shipped alongside it.

Which unrelated changes are bundled together

CONTRIBUTING.md states: "We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features."

The stated intent covers only TRI-13065/TRI-13070 in packages/trigger-sdk/src/v3/chat.ts. The diff additionally contains:

  • Dashboard agent monthly message quota: apps/webapp/app/services/dashboardAgentQuota.server.ts, internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql, apps/webapp/app/components/dashboard-agent/message-quota.ts.
  • Watch plan limits: apps/webapp/app/services/dashboardAgentWatchLimits.server.ts, apps/webapp/app/services/dashboardAgentWatches.server.ts:353-374.
  • Query API read-only pinning and 429 mapping: apps/webapp/app/services/queryService.server.ts:400, apps/webapp/app/routes/api.v1.query.ts:84-89.
  • Agent query retry cap: internal-packages/dashboard-agent/src/tool-api.ts:189-205.
  • Investigation sweep poison-row handling: apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts:116-153, internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql.
Prompt for agents
CONTRIBUTING.md requires each pull request to address a single issue. This branch bundles the SDK chat-transport fixes (packages/trigger-sdk/src/v3/chat.ts) with several independent webapp/internal-package features: the dashboard agent monthly message quota (dashboardAgentQuota.server.ts, new agent_message_usage migration, message-quota.ts and the UI wiring), watch plan limits (dashboardAgentWatchLimits.server.ts plus enforcement inside createDashboardAgentWatch), the query API read-only pinning and 429 mapping, the agent run_query consecutive-failure cap, and the investigation sweep poison-row backstop. Split these into separate pull requests so each can be reviewed, released and reverted on its own.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

6 changes: 6 additions & 0 deletions .server-changes/agent-message-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

The dashboard agent now comes with a monthly message allowance. A message that fails to send doesn't count against it.
6 changes: 6 additions & 0 deletions .server-changes/agent-watch-plan-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more.
6 changes: 6 additions & 0 deletions .server-changes/query-boundary-and-retry-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Queries stay read-only, and the agent now stops after a few failed queries in a row and answers with what it found instead of spending the whole reply retrying.
6 changes: 6 additions & 0 deletions .server-changes/query-busy-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

When queries are queued up and one is turned away, you now get a clear "try again shortly" instead of an error that looks like a problem with the query itself.
11 changes: 5 additions & 6 deletions apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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";
Expand All @@ -22,14 +23,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)}</p>
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
Upgrade
</LinkButton>
Expand Down
33 changes: 27 additions & 6 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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, parseQuotaReachedResponse } from "./message-quota";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { navigateDestination } from "./navigate-target";
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
Expand Down Expand Up @@ -102,6 +103,9 @@ export function DashboardAgentChat({
onActivityChange?: (chatId: string, activity: TurnActivity | null) => 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 } | null>(null);
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
Expand All @@ -128,6 +132,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,
Expand Down Expand Up @@ -185,9 +201,12 @@ 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 });
// 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 === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT);

const isStreaming = status === "streaming";
// From status, not the last part: the indicator must stay up through silent tool calls.
Expand Down Expand Up @@ -252,6 +271,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)))
Expand All @@ -264,7 +285,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);

Expand Down Expand Up @@ -414,9 +435,9 @@ export function DashboardAgentChat({
/>
)}
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
{quota.kind === "reached" ? (
{atMessageCap ? (
<AgentUpgradeBlock
limit={quota.limit}
limit={messageCapLimit}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
Expand Down
61 changes: 41 additions & 20 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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";
Expand All @@ -16,6 +17,7 @@ export function DashboardAgentDraft({
pageContext,
promotedPrompt,
watchCard,
capReached,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
Expand All @@ -24,6 +26,7 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
watchCard?: React.ReactNode;
capReached?: { limit: number } | null;
}) {
const [input, setInput] = useState("");

Expand All @@ -43,12 +46,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 (
Expand All @@ -57,25 +62,41 @@ export function DashboardAgentDraft({
pageContext={pageContext}
promoted={promotedPrompt}
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}
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>
)
}
/>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
writeLastChat,
} from "./last-chat-storage";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { parseQuotaReachedResponse } from "./message-quota";
import { WatchCard } from "./WatchCard";
import { watchDraftFor } from "./watch-card";
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
Expand Down Expand Up @@ -114,6 +115,8 @@ 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 } | 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
Expand Down Expand Up @@ -260,14 +263,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,
Expand Down Expand Up @@ -309,6 +320,7 @@ export function DashboardAgentPanel({
panelOrg.current = organization.id;
claimChatSlot();
setActive(null);
setCapReached(null);
setLoading(false);
setChats([]);
setChatsLoaded(false);
Expand Down Expand Up @@ -634,6 +646,7 @@ export function DashboardAgentPanel({
pageContext={pageContext}
promotedPrompt={promotedPrompt}
watchCard={watchCardElement}
capReached={capReached}
/>
)}
</AgentPanelColumn>
Expand Down
40 changes: 39 additions & 1 deletion apps/webapp/app/components/dashboard-agent/message-quota.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
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,
resolveMessageQuota,
} from "./message-quota";

describe("resolveMessageQuota", () => {
it("caps a Free plan at the limit", () => {
Expand Down Expand Up @@ -42,6 +49,37 @@ 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 });
});

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,
});
});

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("messageQuotaReachedCopy", () => {
it("is a friendly sentence naming the limit, never the raw code", () => {
const copy = messageQuotaReachedCopy(20);
expect(copy).toContain("all 20 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);
});
});

describe("countUserMessages", () => {
it("counts only what the user sent", () => {
expect(
Expand Down
22 changes: 22 additions & 0 deletions apps/webapp/app/components/dashboard-agent/message-quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ export function resolveMessageQuota({
: { kind: "within", used, limit, remaining };
}

// 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 } | null {
if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) {
return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT };
}
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.
export function messageQuotaReachedCopy(limit: number): string {
return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
}

// 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 {
Expand Down
Loading
Loading