fix(ai): hardened core stream, retry, and abort infrastructure

abort-aware auth retry loop preserving resolver errors; EventStream.end() can no longer strand .result(); Copilot retry honors Retry-After; DSML hold-back only triggers on real section prefixes and marks capped params explicitly; idle-iterator hoists racers with bounded reaction retention; validation errors truncate embedded args; removed dead leaky iterateUntilAbort.
This commit is contained in:
can1357
2026-06-10 01:26:29 +02:00
parent 8b4810c828
commit e6ca4d5763
13 changed files with 379 additions and 312 deletions
+13 -2
View File
@@ -432,8 +432,16 @@ export function streamSimple<TApi extends Api>(
let lastKey: string | undefined;
try {
lastKey = (await apiKeyResolver({ lastChance: false, error: undefined, signal })) || undefined;
} catch {
lastKey = undefined;
} catch (error) {
// A thrown resolver is a broker/OAuth/network failure, not a missing
// key — surface the cause instead of masking it as "No API key".
outer.fail(
new Error(
`Failed to resolve API key for provider ${model.provider}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
),
);
return;
}
if (lastKey === undefined) {
outer.fail(new Error(`No API key for provider: ${model.provider}`));
@@ -446,6 +454,9 @@ export function streamSimple<TApi extends Api>(
// resolver yields the same key it just tried or `undefined`; the
// final step's attempt clears the capture flag so it emits directly.
for (let step = 0; step < AUTH_RETRY_STEPS.length; step++) {
// Caller aborted between attempts: don't mint a fresh token or fire
// another doomed request — emit the captured failure instead.
if (signal?.aborted) break;
const nextKey = await resolveRetryKey(apiKeyResolver, AUTH_RETRY_STEPS[step]!, failure.error, signal);
if (nextKey === undefined || nextKey === lastKey) continue;
lastKey = nextKey;
+14
View File
@@ -49,3 +49,17 @@ export function createAbortSourceTracker(callerSignal?: AbortSignal): AbortSourc
},
};
}
/**
* Race a shared promise against a caller's AbortSignal without coupling the
* underlying work to that signal. The shared promise keeps running (and caches
* its result) even when an individual caller bails out.
*/
export function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request was aborted"));
const { promise: aborted, reject } = Promise.withResolvers<never>();
const onAbort = () => reject(signal.reason ?? new Error("Request was aborted"));
signal.addEventListener("abort", onAbort, { once: true });
return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
}
@@ -1,69 +0,0 @@
function abortReason(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) return reason;
if (typeof reason === "string") return new Error(reason);
return new Error("Request was aborted");
}
/**
* Iterates a provider stream until it yields, ends, errors, or the caller aborts.
*/
export async function* iterateUntilAbort<T>(iterable: AsyncIterable<T>, signal?: AbortSignal): AsyncGenerator<T> {
const iterator = iterable[Symbol.asyncIterator]();
const closeIterator = (): void => {
const returnPromise = iterator.return?.();
if (returnPromise) {
void returnPromise.catch(() => {});
}
};
if (signal?.aborted) {
closeIterator();
throw abortReason(signal);
}
const withResult = (promise: Promise<IteratorResult<T>>) =>
promise.then(
result => ({ kind: "next" as const, result }),
error => ({ kind: "error" as const, error }),
);
while (true) {
if (signal?.aborted) {
closeIterator();
throw abortReason(signal);
}
const racers: Array<
Promise<{ kind: "next"; result: IteratorResult<T> } | { kind: "error"; error: unknown } | { kind: "abort" }>
> = [withResult(iterator.next())];
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (signal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolve({ kind: "abort" });
signal.addEventListener("abort", abortListener, { once: true });
racers.push(promise);
}
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(signal!);
}
if (outcome.kind === "error") {
throw outcome.error;
}
if (outcome.result.done) {
return;
}
yield outcome.result.value;
} finally {
if (abortListener && signal) {
signal.removeEventListener("abort", abortListener);
}
resolveAbort?.({ kind: "abort" });
}
}
}
+17
View File
@@ -5,6 +5,8 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
queue: T[] = [];
waiting: Array<{ resolve: (value: IteratorResult<T>) => void; reject: (err: unknown) => void }> = [];
done = false;
/** True once finalResultPromise has been resolved or rejected. */
resultSettled = false;
#failed = false;
#error: unknown = undefined;
finalResultPromise: Promise<R>;
@@ -30,6 +32,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
if (this.isComplete(event)) {
this.done = true;
this.resultSettled = true;
this.resolveFinalResult(this.extractResult(event));
}
@@ -54,7 +57,13 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
end(result?: R): void {
this.done = true;
if (result !== undefined) {
this.resultSettled = true;
this.resolveFinalResult(result);
} else if (!this.resultSettled) {
// end() without a terminal value must still settle result() —
// otherwise complete()/result() awaits hang forever.
this.resultSettled = true;
this.rejectFinalResult(new Error("Stream ended without a final result"));
}
// Notify all waiting consumers that we're done
while (this.waiting.length > 0) {
@@ -75,6 +84,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
this.done = true;
this.#failed = true;
this.#error = err;
this.resultSettled = true;
this.rejectFinalResult(err);
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
@@ -126,6 +136,7 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
// Completion resolves the final result and still emits the terminal event.
if (this.isComplete(event)) {
this.done = true;
this.resultSettled = true;
this.resolveFinalResult(this.extractResult(event));
}
@@ -135,7 +146,13 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
override end(result?: AssistantMessage): void {
this.done = true;
if (result !== undefined) {
this.resultSettled = true;
this.resolveFinalResult(result);
} else if (!this.resultSettled) {
// Mirror the base class: a result-less end() must not leave
// result() pending forever.
this.resultSettled = true;
this.rejectFinalResult(new Error("Stream ended without a final result"));
}
this.endWaiting();
}
+167 -90
View File
@@ -2,6 +2,8 @@ import { $env } from "@oh-my-pi/pi-utils";
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
/** Re-mint persistent race promises every N iterations (see hoisted-racer comment). */
const RACER_REMINT_INTERVAL = 1024;
function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): number | undefined {
if (value === undefined) return fallback;
@@ -164,109 +166,184 @@ export async function* iterateWithIdleTimeout<T>(
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
while (true) {
let activeTimeoutMs: number | undefined;
if (awaitingFirstItem) {
if (firstItemDeadlineMs !== undefined) {
activeTimeoutMs = firstItemDeadlineMs - Date.now();
if (activeTimeoutMs <= 0) {
options.onFirstItemTimeout?.();
closeIterator();
throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
}
}
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
if (activeTimeoutMs <= 0) {
options.onIdle?.();
closeIterator();
throw new Error(options.errorMessage);
}
// Persistent racers, hoisted out of the per-item loop. The abort promise can
// only ever resolve once (abort latches), and a timeout resolution always
// precedes a throw — so neither needs per-item re-creation. This keeps the
// token hot path free of timer create/destroy and listener churn.
//
// Each Promise.race() call still attaches a reaction record to every pending
// racer, and those records live until the racer settles — so a never-firing
// abort/timeout promise would accumulate one record per streamed item for
// the stream's whole life. The loop re-mints both promises every
// RACER_REMINT_INTERVAL iterations to keep that retention bounded; the
// listener and timer callbacks resolve through late-bound variables so a
// re-mint never strands them.
let abortPromise: Promise<{ kind: "abort" }> | undefined;
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (abortSignal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolveAbort?.({ kind: "abort" });
abortSignal.addEventListener("abort", abortListener, { once: true });
abortPromise = promise;
}
let timeoutPromise: Promise<{ kind: "timeout" }> | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
let timeoutFired = false;
let timer: NodeJS.Timeout | undefined;
let timerFireAtMs = Infinity;
const currentDeadlineMs = (): number | undefined => {
if (awaitingFirstItem) return firstItemDeadlineMs;
if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
return lastProgressAt + options.idleTimeoutMs;
}
const nextResultPromise = withRacy(iterator.next());
const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
let timer: NodeJS.Timeout | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
if (enforceTimeout) {
return undefined;
};
const onTimerFire = (): void => {
timer = undefined;
timerFireAtMs = Infinity;
const deadlineMs = currentDeadlineMs();
if (deadlineMs === undefined) return;
const remainingMs = deadlineMs - Date.now();
if (remainingMs > 0) {
// Progress moved the deadline since this timer was armed — re-arm for
// the remainder. One stale wake per idle period, not one per item.
timerFireAtMs = deadlineMs;
timer = setTimeout(onTimerFire, remainingMs);
return;
}
timeoutFired = true;
resolveTimeout?.({ kind: "timeout" });
};
const armTimer = (deadlineMs: number): void => {
if (timeoutPromise === undefined || timeoutFired) {
// A fired-but-unconsumed resolution (the item won the same race) is
// stale — racing it again would fake a timeout, so mint a fresh one.
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
timeoutPromise = promise;
resolveTimeout = resolve;
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
racers.push(promise);
timeoutFired = false;
}
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (abortSignal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolve({ kind: "abort" });
abortSignal.addEventListener("abort", abortListener, { once: true });
racers.push(promise);
if (timer !== undefined) {
// An armed timer firing at or before the new deadline re-arms itself.
if (timerFireAtMs <= deadlineMs) return;
clearTimeout(timer);
}
timerFireAtMs = deadlineMs;
timer = setTimeout(onTimerFire, Math.max(0, deadlineMs - Date.now()));
};
// Tracks whether this iteration handed an item to the consumer and resumed
// normally. Any other exit — internal throw, `done` return, or the consumer
// abandoning us via `.return()`/`.throw()` at the `yield` below — must close
// the upstream iterator so the underlying SSE body / SDK stream (and its
// socket) is released instead of being left suspended.
let continuing = false;
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(abortSignal!);
}
if (outcome.kind === "timeout") {
if (!awaitingFirstItem) {
options.onIdle?.();
} else {
options.onFirstItemTimeout?.();
try {
let raceCount = 0;
while (true) {
if (++raceCount % RACER_REMINT_INTERVAL === 0) {
if (abortPromise !== undefined && !abortSignal!.aborted) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortPromise = promise;
}
if (timeoutPromise !== undefined && !timeoutFired) {
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
resolveTimeout = resolve;
timeoutPromise = promise;
}
closeIterator();
throw new Error(
!awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
);
}
if (outcome.kind === "error") {
throw outcome.error;
let activeTimeoutMs: number | undefined;
if (awaitingFirstItem) {
if (firstItemDeadlineMs !== undefined) {
activeTimeoutMs = firstItemDeadlineMs - Date.now();
if (activeTimeoutMs <= 0) {
options.onFirstItemTimeout?.();
closeIterator();
throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
}
}
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
if (activeTimeoutMs <= 0) {
options.onIdle?.();
closeIterator();
throw new Error(options.errorMessage);
}
}
if (outcome.result.done) {
markFirstItemReceived();
return;
const nextResultPromise = withRacy(iterator.next());
const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
if (enforceTimeout) {
armTimer(Date.now() + activeTimeoutMs!);
racers.push(timeoutPromise!);
}
const item = outcome.result.value;
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
// arrive before the model has produced any tokens) MUST NOT flip us out of
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
// on the model's first real output.
if (isProgressItem(item)) {
markFirstItemReceived();
lastProgressAt = Date.now();
if (abortPromise) {
racers.push(abortPromise);
}
yield item;
continuing = true;
} finally {
if (!continuing) closeIterator();
if (timer !== undefined) clearTimeout(timer);
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
resolveTimeout?.({ kind: "timeout" });
if (abortListener && abortSignal) {
abortSignal.removeEventListener("abort", abortListener);
// Tracks whether this iteration handed an item to the consumer and resumed
// normally. Any other exit — internal throw, `done` return, or the consumer
// abandoning us via `.return()`/`.throw()` at the `yield` below — must close
// the upstream iterator so the underlying SSE body / SDK stream (and its
// socket) is released instead of being left suspended.
let continuing = false;
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(abortSignal!);
}
if (outcome.kind === "timeout") {
if (!awaitingFirstItem) {
options.onIdle?.();
} else {
options.onFirstItemTimeout?.();
}
closeIterator();
throw new Error(
!awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
);
}
if (outcome.kind === "error") {
throw outcome.error;
}
if (outcome.result.done) {
markFirstItemReceived();
return;
}
const item = outcome.result.value;
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
// arrive before the model has produced any tokens) MUST NOT flip us out of
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
// on the model's first real output.
if (isProgressItem(item)) {
markFirstItemReceived();
lastProgressAt = Date.now();
}
yield item;
continuing = true;
} finally {
if (!continuing) closeIterator();
}
resolveAbort?.({ kind: "abort" });
}
} finally {
if (timer !== undefined) clearTimeout(timer);
// Settle the persistent racers so the final Promise.race releases them.
resolveTimeout?.({ kind: "timeout" });
if (abortListener && abortSignal) {
abortSignal.removeEventListener("abort", abortListener);
}
resolveAbort?.({ kind: "abort" });
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function getRetryAfterMsFromHeaders(headers: HeadersLike): number | undef
return Math.max(...candidates);
}
function getHeadersFromError(error: unknown): HeadersLike {
export function getHeadersFromError(error: unknown): HeadersLike {
if (!error || typeof error !== "object") return undefined;
const record = error as { headers?: unknown; response?: { headers?: unknown }; cause?: unknown };
const direct = extractHeaders(record.headers) ?? extractHeaders(record.response?.headers);
+19 -2
View File
@@ -1,5 +1,6 @@
import { scheduler } from "node:timers/promises";
import { extractHttpStatusFromError, isRetryableError } from "@oh-my-pi/pi-utils";
import { getHeadersFromError, getRetryAfterMsFromHeaders } from "./retry-after";
/**
* GitHub Copilot intermittently rejects preview models (gpt-5.3-codex,
@@ -24,6 +25,8 @@ export function isCopilotTransientModelError(error: unknown): boolean {
const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3;
const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
/** Longest server-requested backoff we are willing to sit out before giving up. */
const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000;
/**
* Wrap an initial Copilot request so transient `model_not_supported` 400s are
@@ -49,9 +52,23 @@ export async function callWithCopilotModelRetry<T>(
// guaranteed-dead attempt — surface the original error, not the
// scheduler's AbortError.
if (options.signal?.aborted) throw error;
if (!isCopilotTransientModelError(error) && !isRetryableError(error)) throw error;
const transientModelError = isCopilotTransientModelError(error);
if (!transientModelError && !isRetryableError(error)) throw error;
if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break;
await scheduler.wait(retryBaseDelayMs * (attempt + 1), { signal: options.signal });
let delayMs = retryBaseDelayMs * (attempt + 1);
if (!transientModelError) {
const status = extractHttpStatusFromError(error);
if (status !== undefined) {
// Status-bearing retryable errors (429/5xx) are only re-sent when
// the server told us when to come back — a blind fixed-delay retry
// of a rate limit just burns the remaining attempts. Status-less
// transport blips (socket close, h2 reset) keep the linear backoff.
const retryAfterMs = getRetryAfterMsFromHeaders(getHeadersFromError(error));
if (retryAfterMs === undefined || retryAfterMs > COPILOT_RETRY_AFTER_MAX_WAIT_MS) throw error;
delayMs = Math.max(delayMs, retryAfterMs);
}
}
await scheduler.wait(delayMs, { signal: options.signal });
}
}
throw lastError;
+41 -6
View File
@@ -36,6 +36,8 @@ const DSML_PARAMETER_OPEN_RE = new RegExp(
"y",
);
const DSML_PARAMETER_CLOSE_RE = new RegExp(`</${DSML_PIPE}DSML${DSML_PIPE}parameter>`, "y");
/** Canonical DSML section-open shape; `|` positions accept either pipe variant. */
const DSML_SECTION_OPEN_TEMPLATE = "<|DSML|tool_calls>";
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
@@ -81,6 +83,7 @@ type XmlToolState =
readonly paramName: string;
readonly isString: boolean;
value: string;
truncated?: boolean;
};
type ThinkingTag = { readonly open: string; readonly close: string };
@@ -429,12 +432,25 @@ export class StreamMarkupHealing {
continue;
}
} else if (this.#tryMatch(config.parameterClose)) {
state.args[state.paramName] = coerceXmlParamValue(state.value, state.isString);
// A capped value executes with silently corrupted input unless the
// truncation is made explicit — the marker fails JSON params loudly
// and tells the model/tool what happened to string params.
const paramValue = state.truncated
? `${state.value}\n…[parameter truncated: exceeded ${MAX_XML_PARAM_VALUE_LENGTH} bytes]`
: state.value;
state.args[state.paramName] = coerceXmlParamValue(paramValue, state.isString);
config.setState({ kind: "invoke", name: state.invokeName, args: state.args });
continue;
}
if (this.#startsWithPartialXmlTag()) break;
if (state.kind === "idle") {
// In idle, a bare `<` is legitimate output (`a < b`, generics, JSX).
// Only hold back tails that could still grow into the DSML
// section-open tag; everything else flows through immediately.
if (this.#startsWithPartialDsmlSectionOpen()) break;
} else if (this.#startsWithPartialXmlTag()) {
break;
}
const ch = this.#buffer[this.#offset]!;
this.#offset += 1;
@@ -443,11 +459,15 @@ export class StreamMarkupHealing {
continue;
}
if (state.kind === "parameter") {
if (state.value.length >= MAX_XML_PARAM_VALUE_LENGTH) {
config.setState({ kind: "idle" });
continue;
if (state.value.length < MAX_XML_PARAM_VALUE_LENGTH) {
state.value += ch;
} else {
// Beyond the cap the value stops growing, but we stay in
// `parameter` state so the rest of the envelope — including its
// close tags — is still swallowed instead of leaking into
// visible text. The close handler appends an explicit marker.
state.truncated = true;
}
state.value += ch;
}
}
@@ -511,6 +531,21 @@ export class StreamMarkupHealing {
return true;
}
#startsWithPartialDsmlSectionOpen(): boolean {
const tailLength = this.#buffer.length - this.#offset;
if (tailLength === 0 || tailLength >= DSML_SECTION_OPEN_TEMPLATE.length) return false;
for (let i = 0; i < tailLength; i++) {
const ch = this.#buffer[this.#offset + i]!;
const expected = DSML_SECTION_OPEN_TEMPLATE[i]!;
if (expected === "|") {
if (ch !== "|" && ch !== "|") return false;
} else if (ch !== expected) {
return false;
}
}
return true;
}
#bufferIsPrefixOf(token: string, remainingLength: number): boolean {
for (let i = 0; i < remainingLength; i++) {
if (this.#buffer[this.#offset + i] !== token[i]) return false;
+23 -3
View File
@@ -979,6 +979,23 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a
return validateToolArguments(tool, toolCall);
}
/** Cap per-field string lengths when embedding received args in an error message. */
const MAX_ERROR_ARG_STRING_LENGTH = 256;
function truncateArgsForError(value: unknown): unknown {
if (typeof value === "string") {
if (value.length <= MAX_ERROR_ARG_STRING_LENGTH) return value;
return `${value.slice(0, MAX_ERROR_ARG_STRING_LENGTH)}… [truncated ${value.length - MAX_ERROR_ARG_STRING_LENGTH} chars]`;
}
if (Array.isArray(value)) return value.map(truncateArgsForError);
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) out[key] = truncateArgsForError(entry);
return out;
}
return value;
}
/**
* Validates tool call arguments against the tool's schema (Zod or plain JSON
* Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
@@ -1025,12 +1042,15 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
// existing tests; the detailed body is informational.
const errors = result.messages.join("\n") || "Unknown validation error";
// Truncate long per-field strings: the full payload (potentially hundreds
// of KB for write/edit-class calls) would otherwise round-trip back to the
// model inside the tool error.
const receivedArgs = changed
? {
original: originalArgs,
normalized: normalizedArgs,
original: truncateArgsForError(originalArgs),
normalized: truncateArgsForError(normalizedArgs),
}
: originalArgs;
: truncateArgsForError(originalArgs);
const errorMessage = `Validation failed for tool "${
toolCall.name
-139
View File
@@ -1,139 +0,0 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { iterateUntilAbort } from "@oh-my-pi/pi-ai/utils/abortable-iterator";
function makeSource<T>(handlers: { next: () => Promise<IteratorResult<T>>; onReturn?: () => void }): AsyncIterable<T> {
return {
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: handlers.next,
async return(): Promise<IteratorResult<T>> {
handlers.onReturn?.();
return { done: true, value: undefined as unknown as T };
},
};
},
};
}
describe("iterateUntilAbort", () => {
it("observes aborts that happen between yielded items and calls iterator.return()", async () => {
const controller = new AbortController();
let nextCalls = 0;
let returnCalled = false;
const source = makeSource<number>({
next: async () => {
nextCalls += 1;
if (nextCalls === 1) return { done: false, value: 1 };
const { promise } = Promise.withResolvers<IteratorResult<number>>();
return promise;
},
onReturn: () => {
returnCalled = true;
},
});
const iterator = iterateUntilAbort(source, controller.signal);
await expect(iterator.next()).resolves.toEqual({ done: false, value: 1 });
controller.abort();
await expect(iterator.next()).rejects.toThrow(/abort/i);
expect(nextCalls).toBe(1);
expect(returnCalled).toBe(true);
});
it("observes aborts that fire DURING an in-flight iterator.next()", async () => {
const controller = new AbortController();
let returnCalled = false;
const source = makeSource<number>({
next: async () => {
const { promise } = Promise.withResolvers<IteratorResult<number>>();
return promise; // never resolves
},
onReturn: () => {
returnCalled = true;
},
});
const iterator = iterateUntilAbort(source, controller.signal);
const pending = iterator.next();
setTimeout(() => controller.abort(new Error("torn down")), 5);
await expect(pending).rejects.toThrow(/torn down/);
expect(returnCalled).toBe(true);
});
it("rejects immediately when the signal is already aborted before the first next()", async () => {
const controller = new AbortController();
controller.abort(new Error("preflight"));
let returnCalled = false;
const source = makeSource<number>({
next: async () => ({ done: false, value: 1 }),
onReturn: () => {
returnCalled = true;
},
});
const iterator = iterateUntilAbort(source, controller.signal);
await expect(iterator.next()).rejects.toThrow(/preflight/);
expect(returnCalled).toBe(true);
});
it("yields every item and terminates cleanly when the source completes naturally", async () => {
const items = [1, 2, 3];
let i = 0;
const source = makeSource<number>({
next: async () =>
i < items.length
? { done: false, value: items[i++]! }
: { done: true, value: undefined as unknown as number },
});
const collected: number[] = [];
for await (const item of iterateUntilAbort(source)) {
collected.push(item);
}
expect(collected).toEqual(items);
});
it("propagates errors from the underlying iterator.next()", async () => {
const source = makeSource<number>({
next: async () => {
throw new Error("upstream blew up");
},
});
await expect(async () => {
for await (const _ of iterateUntilAbort(source)) {
// no body
}
}).toThrow("upstream blew up");
});
it("does not leak abort listeners across iterations", async () => {
const controller = new AbortController();
const addSpy = vi.spyOn(controller.signal, "addEventListener");
const removeSpy = vi.spyOn(controller.signal, "removeEventListener");
const items = [1, 2, 3, 4, 5];
let i = 0;
const source = makeSource<number>({
next: async () =>
i < items.length
? { done: false, value: items[i++]! }
: { done: true, value: undefined as unknown as number },
});
for await (const _ of iterateUntilAbort(source, controller.signal)) {
// no body
}
// Every addEventListener("abort", ...) must be paired with a removeEventListener
// call (no leaks across iterations).
const adds = addSpy.mock.calls.filter(([type]) => type === "abort").length;
const removes = removeSpy.mock.calls.filter(([type]) => type === "abort").length;
expect(adds).toBe(removes);
expect(adds).toBeGreaterThan(0);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
+51
View File
@@ -120,6 +120,57 @@ describe("callWithCopilotModelRetry", () => {
expect(calls).toBe(1);
});
it("does not blind-retry a 429 that carries no Retry-After guidance", async () => {
let calls = 0;
const err = copilotError({ status: 429, message: "rate limited" });
await expect(
callWithCopilotModelRetry(
async () => {
calls += 1;
throw err;
},
{ provider: "github-copilot", retryBaseDelayMs: 0 },
),
).rejects.toBe(err);
expect(calls).toBe(1);
});
it("honors Retry-After on a 429 and retries", async () => {
let calls = 0;
const result = await callWithCopilotModelRetry(
async () => {
calls += 1;
if (calls === 1) {
const err = copilotError({ status: 429, message: "rate limited" });
(err as unknown as { headers: Record<string, string> }).headers = { "retry-after": "0.01" };
throw err;
}
return "ok" as const;
},
{ provider: "github-copilot", retryBaseDelayMs: 0 },
);
expect(result).toBe("ok");
expect(calls).toBe(2);
});
it("still retries status-less transport blips with the linear backoff", async () => {
let calls = 0;
const result = await callWithCopilotModelRetry(
async () => {
calls += 1;
if (calls === 1) {
throw new Error(
'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()',
);
}
return "ok" as const;
},
{ provider: "github-copilot", retryBaseDelayMs: 0 },
);
expect(result).toBe("ok");
expect(calls).toBe(2);
});
it("stops retrying when the caller aborts during backoff", async () => {
const controller = new AbortController();
controller.abort();
+14
View File
@@ -33,4 +33,18 @@ describe("AssistantMessageEventStream", () => {
expect(stream.queue[0]).toMatchObject({ type: "text_delta", delta: "a" });
expect(stream.queue[1]).toMatchObject({ type: "text_delta", delta: "b" });
});
it("rejects result() when ended without a terminal value", async () => {
const stream = new AssistantMessageEventStream();
stream.end();
await expect(stream.result()).rejects.toThrow(/ended without a final result/);
});
it("keeps the pushed terminal result when end() follows a done event", async () => {
const stream = new AssistantMessageEventStream();
const message = createPartial("final");
stream.push({ type: "done", reason: "stop", message });
stream.end();
await expect(stream.result()).resolves.toBe(message);
});
});
@@ -218,6 +218,25 @@ describe("StreamMarkupHealing DSML envelope pattern", () => {
expect(calls[0].name).toBe("bash");
expect(JSON.parse(calls[0].arguments)).toEqual({ cmd: "ls -la" });
});
it("passes a bare '<' in idle prose through without holding it back", () => {
const healing = new StreamMarkupHealing({ pattern: "dsml" });
// No '>' anywhere in the tail — the old any-'<' hold-back froze display here.
expect(healing.feed("if a < b:\n return a")).toBe("if a < b:\n return a");
});
it("still holds back a tail that is a partial DSML section-open tag", () => {
const healing = new StreamMarkupHealing({ pattern: "dsml" });
expect(healing.feed("run ")).toBe("run ");
expect(healing.feed("<|DSML|tool")).toBe("");
expect(healing.feed("_calls>")).toBe("");
expect(
healing.feed(
'<|DSML|invoke name="bash"><|DSML|parameter name="cmd">ls</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>',
),
).toBe("");
expect(healing.drainCompleted()).toHaveLength(1);
});
});
describe("StreamMarkupHealing thinking pattern", () => {