Skip to content

Commit af86a21

Browse files
committed
Merge remote-tracking branch 'origin/test/chat-agent-durability-tri-11166' into feat/agent-message-quota-tri-12863
2 parents 097bb5d + c42002b commit af86a21

5 files changed

Lines changed: 172 additions & 7 deletions

File tree

.changeset/watch-mode-keepalive.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
---
44

55
Watch-mode chat subscriptions now stay connected across quiet periods.
6+
7+
Read-only chat subscriptions no longer stop a turn when they disconnect.

apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap

Lines changed: 0 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* The email gate is the one place the installation's mail config decides whether a watch may
3+
* subscribe. Both variables are required: with either one missing the channel would be created
4+
* but never deliver, so the refusal has to come off the env, not off the caller.
5+
*/
6+
7+
import { beforeEach, describe, expect, test, vi } from "vitest";
8+
9+
const mocks = vi.hoisted(() => ({
10+
env: {} as { ALERT_FROM_EMAIL?: string; ALERT_EMAIL_TRANSPORT?: string },
11+
canAccessDashboardAgent: vi.fn(async () => true),
12+
}));
13+
14+
vi.mock("~/env.server", () => ({ env: mocks.env }));
15+
vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
16+
canAccessDashboardAgent: mocks.canAccessDashboardAgent,
17+
}));
18+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {}, sqlDatabaseSchema: undefined }));
19+
vi.mock("~/v3/alertsWorker.server", () => ({ alertsWorker: { enqueue: vi.fn() } }));
20+
vi.mock("~/v3/services/alerts/createAlertChannel.server", () => ({
21+
CreateAlertChannelService: class {},
22+
}));
23+
vi.mock("~/services/logger.server", () => ({
24+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
25+
}));
26+
27+
import { canUseDashboardAgentEmailAlerts } from "~/services/dashboardAgentWatchAlerts.server";
28+
29+
const PARAMS = {
30+
userId: "usr_1",
31+
organizationId: "org_1",
32+
organizationSlug: "acme",
33+
orgFeatureFlags: null,
34+
projectId: "proj_1",
35+
};
36+
37+
describe("canUseDashboardAgentEmailAlerts", () => {
38+
beforeEach(() => {
39+
mocks.env.ALERT_FROM_EMAIL = "alerts@example.com";
40+
mocks.env.ALERT_EMAIL_TRANSPORT = "smtp";
41+
});
42+
43+
test("refuses when only the from address is missing", async () => {
44+
mocks.env.ALERT_FROM_EMAIL = undefined;
45+
46+
await expect(canUseDashboardAgentEmailAlerts(PARAMS)).resolves.toEqual({
47+
allowed: false,
48+
reason: "email_alerts_not_configured",
49+
});
50+
});
51+
52+
test("refuses when only the transport is missing", async () => {
53+
mocks.env.ALERT_EMAIL_TRANSPORT = undefined;
54+
55+
await expect(canUseDashboardAgentEmailAlerts(PARAMS)).resolves.toEqual({
56+
allowed: false,
57+
reason: "email_alerts_not_configured",
58+
});
59+
});
60+
61+
test("allows when both are configured and the base gate passes", async () => {
62+
await expect(canUseDashboardAgentEmailAlerts(PARAMS)).resolves.toEqual({ allowed: true });
63+
});
64+
});

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,94 @@ describe("TriggerChatTransport", () => {
13341334
});
13351335
});
13361336

1337+
describe("reconnectToStream stop-on-abort ownership (TRI-13070)", () => {
1338+
// A quiet stream: EOF, no records, never settled — the subscription
1339+
// stays alive (watch mode) so an abort mid-flight exercises the stop path.
1340+
function quietWatchTransport(): {
1341+
transport: TriggerChatTransport;
1342+
appends: () => number;
1343+
} {
1344+
let appendCount = 0;
1345+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1346+
const urlStr = typeof url === "string" ? url : url.toString();
1347+
if (isSessionStreamAppendUrl(urlStr)) {
1348+
appendCount++;
1349+
return defaultAppendResponse();
1350+
}
1351+
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]);
1352+
throw new Error(`Unexpected URL: ${urlStr}`);
1353+
});
1354+
const transport = new TriggerChatTransport({
1355+
task: "my-chat-task",
1356+
accessToken: () => "pat",
1357+
watch: true,
1358+
sessions: { "chat-own": { publicAccessToken: "p", isStreaming: true } },
1359+
});
1360+
return { transport, appends: () => appendCount };
1361+
}
1362+
1363+
it("passive subscriber aborting writes no stop chunk to .in", async () => {
1364+
vi.useFakeTimers();
1365+
try {
1366+
const { transport, appends } = quietWatchTransport();
1367+
const abort = new AbortController();
1368+
const stream = await transport.reconnectToStream({
1369+
chatId: "chat-own",
1370+
abortSignal: abort.signal,
1371+
});
1372+
const drained = drainChunks(stream!);
1373+
await vi.advanceTimersByTimeAsync(1_000);
1374+
abort.abort();
1375+
await drained;
1376+
await vi.advanceTimersByTimeAsync(1_000);
1377+
expect(appends()).toBe(0);
1378+
} finally {
1379+
vi.useRealTimers();
1380+
}
1381+
});
1382+
1383+
it("owning subscriber with stopOnAbort:true sends a stop chunk on abort", async () => {
1384+
vi.useFakeTimers();
1385+
try {
1386+
const { transport, appends } = quietWatchTransport();
1387+
const abort = new AbortController();
1388+
const stream = await transport.reconnectToStream({
1389+
chatId: "chat-own",
1390+
abortSignal: abort.signal,
1391+
stopOnAbort: true,
1392+
});
1393+
const drained = drainChunks(stream!);
1394+
await vi.advanceTimersByTimeAsync(1_000);
1395+
abort.abort();
1396+
await drained;
1397+
await vi.advanceTimersByTimeAsync(1_000);
1398+
expect(appends()).toBe(1);
1399+
} finally {
1400+
vi.useRealTimers();
1401+
}
1402+
});
1403+
1404+
it("abortSignal presence alone (stopOnAbort unset) sends no stop", async () => {
1405+
vi.useFakeTimers();
1406+
try {
1407+
const { transport, appends } = quietWatchTransport();
1408+
const abort = new AbortController();
1409+
const stream = await transport.reconnectToStream({
1410+
chatId: "chat-own",
1411+
abortSignal: abort.signal,
1412+
});
1413+
const drained = drainChunks(stream!);
1414+
await vi.advanceTimersByTimeAsync(1_000);
1415+
abort.abort();
1416+
await drained;
1417+
await vi.advanceTimersByTimeAsync(1_000);
1418+
expect(appends()).toBe(0);
1419+
} finally {
1420+
vi.useRealTimers();
1421+
}
1422+
});
1423+
});
1424+
13371425
describe("multi-tab coordination", () => {
13381426
it("isReadOnly defaults to false when multiTab is disabled", () => {
13391427
const transport = new TriggerChatTransport({

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
873873
state.isStreaming = true;
874874
this.notifySessionChange(chatId, state);
875875

876-
return this.subscribeToSessionStream(state, abortSignal, chatId, { sinceInSeq: inSeq });
876+
// Owning turn: aborting this live send stops the turn the user drives.
877+
return this.subscribeToSessionStream(state, abortSignal, chatId, {
878+
sinceInSeq: inSeq,
879+
sendStopOnAbort: true,
880+
});
877881
};
878882

879883
/**
@@ -1146,6 +1150,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11461150
options: {
11471151
chatId: string;
11481152
abortSignal?: AbortSignal | undefined;
1153+
/**
1154+
* Whether aborting this subscription sends `{kind:"stop"}` on `.in`.
1155+
* A subscription ending is not session ownership — a passive/watch
1156+
* reader unmounting must never stop a turn it doesn't drive. Only
1157+
* pass `true` from a caller that owns the live turn. @default false
1158+
*/
1159+
stopOnAbort?: boolean;
11491160
} & ChatRequestOptions
11501161
): Promise<ReadableStream<UIMessageChunk> | null> => {
11511162
const state = this.sessions.get(options.chatId);
@@ -1163,7 +1174,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11631174

11641175
return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
11651176
resumed: true,
1166-
sendStopOnAbort: !!options.abortSignal,
1177+
sendStopOnAbort: options.stopOnAbort ?? false,
11671178
// Reconnect-on-reload opts into the server's settled-peek shortcut
11681179
// so the SSE doesn't hang for 60s when no turn is in flight. Active
11691180
// send-a-message paths must keep wait=60 to avoid racing the
@@ -1266,7 +1277,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12661277
state.isStreaming = true;
12671278
this.notifySessionChange(chatId, state);
12681279

1269-
return this.subscribeToSessionStream(state, undefined, chatId, { sinceInSeq: inSeq });
1280+
// Owning action: aborting this send stops the turn the user drives.
1281+
return this.subscribeToSessionStream(state, undefined, chatId, {
1282+
sinceInSeq: inSeq,
1283+
sendStopOnAbort: true,
1284+
});
12701285
};
12711286

12721287
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)