Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 93 additions & 0 deletions packages/trigger-sdk/src/v3/ai-shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { anySignal } from "./ai-shared.js";

describe("anySignal", () => {
it("returns a non-aborted signal when signals list is empty", () => {
Comment on lines +4 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.

🔍 Identify each unit test’s regression

The new unit tests lack the required // Catches: <bug> comments. Identify each distinct regression they protect and remove redundant cases.

Devin Review


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

const signal = anySignal([]);
expect(signal.aborted).toBe(false);
});

it("filters out null and undefined values", () => {
const controller = new AbortController();
const signal = anySignal([null, undefined, controller.signal, null]);
expect(signal).toBe(controller.signal);
});

it("returns the single signal if only one valid signal is provided", () => {
const controller = new AbortController();
const signal = anySignal([controller.signal]);
expect(signal).toBe(controller.signal);
});

describe("when AbortSignal.any is available (native)", () => {
it("aborts when any of the input signals aborts", () => {
const c1 = new AbortController();
const c2 = new AbortController();
const combined = anySignal([c1.signal, c2.signal]);

expect(combined.aborted).toBe(false);
c2.abort("cancelled by c2");
expect(combined.aborted).toBe(true);
expect(combined.reason).toBe("cancelled by c2");
});
});

describe("fallback when AbortSignal.any is undefined (Safari < 17.4, Node < 20.3)", () => {
const originalAny = AbortSignal.any;

beforeEach(() => {
// @ts-expect-error - simulating environment without AbortSignal.any
delete AbortSignal.any;
});

afterEach(() => {
AbortSignal.any = originalAny;
});

it("returns an immediately aborted signal if an input signal is already aborted", () => {
const c1 = new AbortController();
const c2 = new AbortController();
c1.abort("already aborted reason");

const combined = anySignal([c1.signal, c2.signal]);
expect(combined.aborted).toBe(true);
expect(combined.reason).toBe("already aborted reason");
});

it("aborts when the first signal is aborted", () => {
const c1 = new AbortController();
const c2 = new AbortController();
const combined = anySignal([c1.signal, c2.signal]);

expect(combined.aborted).toBe(false);
c1.abort("first reason");
expect(combined.aborted).toBe(true);
expect(combined.reason).toBe("first reason");
});

it("aborts when the second signal is aborted", () => {
const c1 = new AbortController();
const c2 = new AbortController();
const combined = anySignal([c1.signal, c2.signal]);

expect(combined.aborted).toBe(false);
c2.abort("second reason");
expect(combined.aborted).toBe(true);
expect(combined.reason).toBe("second reason");
});

it("cleans up event listeners on remaining signals when one signal aborts", () => {
const c1 = new AbortController();
const c2 = new AbortController();

const c2RemoveSpy = vi.spyOn(c2.signal, "removeEventListener");

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.

🟡 Fallback test uses a forbidden spy

The listener-cleanup test calls vi.spyOn, despite the repository's explicit ban on spies in tests. Its assertion also couples the test to listener-removal internals.

Learn more

Example: A fallback implementation that manages listeners through another valid mechanism can fail the test solely because it never calls the spied-on method.

Recommended fix: Replace the spy with an observable behavioral test that covers the listener-lifetime regression without intercepting removeEventListener.

Devin Review


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

const combined = anySignal([c1.signal, c2.signal]);

expect(combined.aborted).toBe(false);
c1.abort("first aborted");

expect(combined.aborted).toBe(true);
expect(c2RemoveSpy).toHaveBeenCalledWith("abort", expect.any(Function));
});
});
});
47 changes: 47 additions & 0 deletions packages/trigger-sdk/src/v3/ai-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,50 @@ export function waitBeforeEofResubscribe(attempt: number, signal: AbortSignal):
if (signal.aborted) done();
});
}

/**
* Combines multiple AbortSignals into a single signal.
*
* Uses native `AbortSignal.any` when available, and falls back to an AbortController
* with listeners on environments where `AbortSignal.any` is not supported (such as
* Safari < 17.4 or Node < 20.3).
*/
export function anySignal(signals: (AbortSignal | undefined | null)[]): AbortSignal {

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.

🟡 Safari fix lacks a release changeset

The anySignal fallback fixes a user-visible SDK failure, but this PR includes no changeset. The SDK release loses its required patch note.

Learn more

The SDK is a public package, and the new fallback lets customers use chat transports on older Safari versions that previously threw. Repository guidance requires a changeset when a public package fixes a bug users could hit. No changeset is added in this PR.

Example: A customer deciding whether to upgrade for Safari support reads the SDK changelog and finds no entry for the fix.

Recommended fix: Add a patch changeset for @trigger.dev/sdk, phrased in terms of older Safari chat transport compatibility.

Devin Review


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

const validSignals = signals.filter((s): s is AbortSignal => s != null);
const first = validSignals[0];
if (!first) {
return new AbortController().signal;
}
if (validSignals.length === 1) {
return first;
}
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
return AbortSignal.any(validSignals);
}

const controller = new AbortController();
const alreadyAborted = validSignals.find((s) => s.aborted);
if (alreadyAborted) {
controller.abort(alreadyAborted.reason);
return controller.signal;
}

const onAbort = (e: Event) => {
const s = e.target as AbortSignal;
controller.abort(s?.reason);
cleanup();
};

const cleanup = () => {
for (const s of validSignals) {
s.removeEventListener("abort", onAbort);
}
};

for (const signal of validSignals) {
signal.addEventListener("abort", onAbort, { once: true });
}
Comment on lines +321 to +323

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.

🟡 Completed turns retain abort listeners

On runtimes without AbortSignal.any, anySignal retains listeners after a normal chat turn completes. Neither runSignal nor the per-turn stop signal aborts, so long-lived runs accumulate listeners and controllers.

Learn more

The fallback subscribes to each input signal and removes those listeners only when an input aborts. A managed chat turn creates a new stop controller and combines it with the run-wide signal in the turn setup. Normal completion does not abort either signal, so the run-wide signal keeps every prior turn's closure, controller, and stop signal alive. The custom session turn also replaces its stop controller without aborting the old one in reset. This differs from native AbortSignal.any, which does not keep an otherwise unreferenced composite alive through strong event listeners.

Example: On a runtime without AbortSignal.any, a chat agent finishes 80 normal turns. Its run-wide signal still holds 80 listeners referencing obsolete stop controllers until the run ends.

Recommended fix: Provide a way to dispose the fallback's source listeners when the composite's operation ends, and invoke it at each turn and subscription teardown. Preserve the existing abort propagation and reason while an operation remains active.

Devin Review


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


return controller.signal;
}

5 changes: 3 additions & 2 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {
type InferChatClientData,
type InferChatUIMessage,
type InferChatUIMessageFromTools,
anySignal,
BACKGROUND_INJECTION_TYPE,
PENDING_MESSAGE_INJECTED_TYPE,
upsertIncomingMessage,
Expand Down Expand Up @@ -11072,7 +11073,7 @@ function chatAgent<
// Three signals for the user's run function
const stopSignal = stopController.signal;
const cancelSignal = runSignal;
const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
const combinedSignal = anySignal([runSignal, stopController.signal]);

const pmConfig = locals.get(chatPendingMessagesKey);
/**
Expand Down Expand Up @@ -15859,7 +15860,7 @@ function createChatSession<TClientData = unknown>(
return { done: true, value: undefined };
}

const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
const combinedSignal = anySignal([runSignal, stop.signal]);

const turnObj: ChatTurn<TClientData> = {
number: turn,
Expand Down
3 changes: 2 additions & 1 deletion packages/trigger-sdk/src/v3/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "@trigger.dev/core/v3";
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
import {
anySignal,
chatRunTags,
MAX_EOF_RESUBSCRIBES,
slimSubmitMessageForWire,
Expand Down Expand Up @@ -815,7 +816,7 @@ export class AgentChat<TAgent = unknown> {

const internalAbort = new AbortController();
const combinedSignal = abortSignal
? AbortSignal.any([abortSignal, internalAbort.signal])
? anySignal([abortSignal, internalAbort.signal])
: internalAbort.signal;

// Detached once the stream ends (see the `finally` below): a caller reusing
Expand Down
4 changes: 2 additions & 2 deletions packages/trigger-sdk/src/v3/chat-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import type {
UIMessageChunk,
} from "ai";
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
import { chatRunTags } from "./ai-shared.js";
import { anySignal, chatRunTags } from "./ai-shared.js";
import { triggerConcurrencyBody } from "./concurrency-shared.js";
import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
import type { SessionTriggerConfigInput } from "./sessions.js";
Expand Down Expand Up @@ -970,7 +970,7 @@ async function openHandoverSession(opts: {
"out",
{
...(customerLastEventId != null ? { lastEventId: customerLastEventId } : {}),
signal: AbortSignal.any([abortController.signal, subscriptionAbort.signal]),
signal: anySignal([abortController.signal, subscriptionAbort.signal]),
onPart: (part) => {
if (part.id) latestEventId = part.id;
},
Expand Down
19 changes: 19 additions & 0 deletions packages/trigger-sdk/src/v3/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2456,6 +2456,25 @@ describe("TriggerChatTransport", () => {
vi.useRealTimers();
}
});

it("safely reconnects and aborts when AbortSignal.any is not supported (Safari < 17.4)", async () => {
const originalAny = AbortSignal.any;
try {
// @ts-expect-error - simulating older browser/runtime without AbortSignal.any
delete AbortSignal.any;

const { transport } = quietWatchTransport();
const abort = new AbortController();
const stream = await transport.reconnectToStream({
chatId: "chat-own",
abortSignal: abort.signal,
});
expect(stream).toBeDefined();
abort.abort();
Comment on lines +2472 to +2473

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.

🔍 Transport abort is not asserted

The Safari test calls abort.abort() without consuming the stream. It cannot detect a subscription that remains open after abort.

Devin Review


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

} finally {
AbortSignal.any = originalAny;
}
});
});

describe("superseded stream teardown", () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/trigger-sdk/src/v3/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function byteLength(body: string): number {
}
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
import {
anySignal,
MAX_EOF_RESUBSCRIBES,
slimSubmitMessageForWire,
waitBeforeEofResubscribe,
Expand Down Expand Up @@ -1355,7 +1356,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.activeStreams.set(options.chatId, abortController);

const abortSignal = options.abortSignal
? AbortSignal.any([options.abortSignal, abortController.signal])
? anySignal([options.abortSignal, abortController.signal])
: abortController.signal;

return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
Expand Down Expand Up @@ -2064,7 +2065,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
let didSettle = false;
this.activeStreams.set(chatId, internalAbort);
const combinedSignal = abortSignal
? AbortSignal.any([abortSignal, internalAbort.signal])
? anySignal([abortSignal, internalAbort.signal])
: internalAbort.signal;

if (abortSignal) {
Expand Down