Skip to content

Commit 574a84d

Browse files
committed
fix(sdk): stopping a turn is an owning-transport action, not an abort side effect (TRI-13070)
1 parent 27b04fb commit 574a84d

3 files changed

Lines changed: 108 additions & 3 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.

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)