fix(client): tolerate AbortSignal without throwIfAborted on React Native (#630)
React Native's abort-controller@3 polyfill exposes only `aborted`, so the direct AI paths threw "undefined is not a function" on Android before any request was sent. Feature-detect throwIfAborted and fall back to `aborted`. Fixes #629 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ad082b556
commit
ec8aba7ff7
5 files changed
+51
-6
No files matched your search
@@ -0,0 +1,12 @@
|
||||
// React Native's AbortSignal polyfill (abort-controller@3) has neither
|
||||
// throwIfAborted() nor reason, so feature-detect before relying on them.
|
||||
export const throwIfAborted = (signal: AbortSignal | undefined) => {
|
||||
if (!signal) return;
|
||||
if (typeof signal.throwIfAborted === "function") {
|
||||
signal.throwIfAborted();
|
||||
return;
|
||||
}
|
||||
if (!signal.aborted) return;
|
||||
if (signal.reason !== undefined) throw signal.reason;
|
||||
throw Object.assign(new Error("The operation was aborted."), { name: "AbortError" });
|
||||
};
|
||||
@@ -65,6 +65,36 @@ describe("direct AI generation client", () => {
|
||||
expect(events.some(e=>e.type==="text-delta"&&e.text==="Hello")).toBe(true);expect(paths).toEqual(["/api/v1/ai/generate/prepare"]);expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
// React Native's abort-controller@3 polyfill exposes only `aborted` (#629).
|
||||
const legacySignal = (aborted) => ({ aborted, addEventListener() {}, removeEventListener() {} });
|
||||
|
||||
test("streams with a React Native AbortSignal that lacks throwIfAborted", async () => {
|
||||
const client = createEdgeEverClient({
|
||||
directAiGeneration: true,
|
||||
fetch: async () => Response.json({ provider: "openai-compatible", baseUrl: "https://model.example/v1", apiKey: "key", modelId: "model", system: "Summarize", prompt: "Body", maxOutputTokens: 32, resultBoundary: { start: "<r>", end: "</r>" } }),
|
||||
providerFetch: async () => new Response('data: {"choices":[{"delta":{"content":"<r>Hello</r>"}}]}\n\ndata: [DONE]\n\n'),
|
||||
});
|
||||
const events = [];
|
||||
await client.streamAiGeneration(
|
||||
{ action: "summarize", title: "Note", contentMarkdown: "Body" },
|
||||
{ signal: legacySignal(false), onEvent: (event) => events.push(event) },
|
||||
);
|
||||
expect(events.some((event) => event.type === "text-delta" && event.text === "Hello")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an aborted React Native AbortSignal with AbortError before preparing", async () => {
|
||||
let prepared = false;
|
||||
const client = createEdgeEverClient({
|
||||
directAiGeneration: true,
|
||||
fetch: async () => { prepared = true; return new Response("{}", { status: 404 }); },
|
||||
});
|
||||
await expect(client.streamAiGeneration(
|
||||
{ action: "summarize", title: "Note", contentMarkdown: "Body" },
|
||||
{ signal: legacySignal(true), onEvent() {} },
|
||||
)).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(prepared).toBe(false);
|
||||
});
|
||||
|
||||
test("fails without instance generation when direct prepare is missing", async () => {
|
||||
const urls = [];
|
||||
const client = createEdgeEverClient({
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "@edgeever/shared";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { companionFailureDiagnostics, sealCompanionProcess } from "@edgeever/shared";
|
||||
import { throwIfAborted } from "./abort-signal";
|
||||
import { createClientAiModel } from "./ai-model";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
@@ -232,7 +233,7 @@ export const streamDirectCompanion = async (
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
for await (const part of result.fullStream) {
|
||||
options.signal?.throwIfAborted();
|
||||
throwIfAborted(options.signal);
|
||||
if (part.type === "error") throw part.error;
|
||||
if (part.type !== "text-delta") continue;
|
||||
const delta = typeof part.text === "string" ? part.text : "";
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
parsePreparedAiGeneration,
|
||||
streamDirectAiGeneration,
|
||||
} from "./ai-direct-stream";
|
||||
import { throwIfAborted } from "./abort-signal";
|
||||
|
||||
import { createPluginCapabilities } from './plugin-capabilities';
|
||||
import type {
|
||||
@@ -690,7 +691,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
payload: unknown,
|
||||
streamOptions: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void },
|
||||
) => {
|
||||
streamOptions.signal?.throwIfAborted();
|
||||
throwIfAborted(streamOptions.signal);
|
||||
const { context, response } = await send(preparePath, {
|
||||
method: "POST", body: JSON.stringify(payload), signal: streamOptions.signal,
|
||||
});
|
||||
@@ -707,10 +708,10 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
prepare: () => Promise<P | null>;
|
||||
run: (prepared: P) => Promise<T>;
|
||||
}): Promise<T> => {
|
||||
args.signal?.throwIfAborted();
|
||||
throwIfAborted(args.signal);
|
||||
const prepared = await args.prepare();
|
||||
if (prepared == null) throw directAiUnavailable();
|
||||
args.signal?.throwIfAborted();
|
||||
throwIfAborted(args.signal);
|
||||
return args.run(prepared);
|
||||
};
|
||||
|
||||
@@ -1077,7 +1078,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
},
|
||||
|
||||
streamAiGeneration: async (payload: AiGenerateInput, streamOptions: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void }) => {
|
||||
streamOptions.signal?.throwIfAborted();
|
||||
throwIfAborted(streamOptions.signal);
|
||||
const { context, response } = await send("/api/v1/ai/generate/prepare", {
|
||||
method: "POST", body: JSON.stringify(payload), signal: streamOptions.signal,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isStepCount, tool, ToolLoopAgent, type LanguageModel } from "ai";
|
||||
import { z } from "zod";
|
||||
import { infographicRequestsTimeline, type InfographicAgentEvent, type InfographicAgentRequest } from "@edgeever/shared";
|
||||
import { throwIfAborted } from "./abort-signal";
|
||||
|
||||
export const runInfographicAgent = async (args: {
|
||||
input: InfographicAgentRequest;
|
||||
@@ -64,7 +65,7 @@ export const runInfographicAgent = async (args: {
|
||||
];
|
||||
const result = await agent.stream({ messages, abortSignal: args.signal });
|
||||
for await (const part of result.fullStream) {
|
||||
args.signal.throwIfAborted();
|
||||
throwIfAborted(args.signal);
|
||||
if (part.type === "error") throw part.error;
|
||||
if (part.type !== "text-delta" || !part.text || !proposed || asked) continue;
|
||||
response += part.text;
|
||||
|
||||
Reference in new issue
Block a user