2f24d4457e
The lazy provider wrapper ignored model.compat.streamIdleTimeoutMs, so Bedrock reasoning models sat on the generic 300s idle watchdog despite ConverseStream sending no ping keepalives; long quiet thinking runs died with "Provider stream stalled while waiting for the next event" during plan writing and todo execution (issue #4758's Bedrock variant, worst on Fable 5 where the display default flipped to omitted). - catalog: BedrockCompat gains streamIdleTimeoutMs; reasoning models get a 600s floor, adaptive-thinking Claude (Opus 4.7+, Sonnet/Opus 5, Fable/Mythos 5) 900s to match direct Anthropic's ping-extended tolerance; explicit compat overrides still win (0 disables). - ai: forwardStream resolves options -> env -> model.compat -> default, and lazy terminal errors carry the structural errorId classification so session auto-retry classifies stalls without text matching.
233 lines
8.5 KiB
TypeScript
233 lines
8.5 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
import { setBedrockProviderModule, streamBedrock } from "@oh-my-pi/pi-ai/providers/register-builtins";
|
|
import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types";
|
|
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
|
|
function createModel(
|
|
overrides: { reasoning?: boolean; compat?: { streamIdleTimeoutMs?: number } } = {},
|
|
): Model<"bedrock-converse-stream"> {
|
|
return buildModel({
|
|
id: "mock-bedrock",
|
|
name: "Mock Bedrock",
|
|
api: "bedrock-converse-stream",
|
|
provider: "amazon-bedrock",
|
|
baseUrl: "https://example.invalid",
|
|
reasoning: overrides.reasoning ?? false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 8192,
|
|
maxTokens: 2048,
|
|
compat: overrides.compat,
|
|
});
|
|
}
|
|
|
|
function createAssistantMessage(
|
|
stopReason: AssistantMessage["stopReason"] = "stop",
|
|
errorMessage?: string,
|
|
): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: errorMessage ? `error: ${errorMessage}` : "ok" }],
|
|
api: "bedrock-converse-stream",
|
|
provider: "amazon-bedrock",
|
|
model: "mock-bedrock",
|
|
usage: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason,
|
|
errorMessage,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
const baseContext: Context = { messages: [] };
|
|
|
|
describe("register-builtins lazy streams", () => {
|
|
it("resolves the outer stream result from source.result() when no terminal event is iterated", async () => {
|
|
const finalMessage = createAssistantMessage("stop");
|
|
const partialMessage = createAssistantMessage("stop");
|
|
const source = {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "start", partial: partialMessage } as const;
|
|
},
|
|
result: async () => finalMessage,
|
|
} as unknown as AssistantMessageEventStream;
|
|
|
|
setBedrockProviderModule({
|
|
streamBedrock: () => source,
|
|
});
|
|
|
|
const stream = streamBedrock(createModel(), baseContext, {});
|
|
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
|
|
|
|
expect(result).not.toBe("timeout");
|
|
if (result === "timeout") {
|
|
throw new Error("Timed out waiting for forwarded stream result");
|
|
}
|
|
expect(result).toEqual(finalMessage);
|
|
});
|
|
|
|
it("turns iterator failures into terminal error results", async () => {
|
|
const partialMessage = createAssistantMessage("stop");
|
|
const source = {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "start", partial: partialMessage } as const;
|
|
throw new Error("bedrock exploded");
|
|
},
|
|
} as unknown as AssistantMessageEventStream;
|
|
|
|
setBedrockProviderModule({
|
|
streamBedrock: () => source,
|
|
});
|
|
|
|
const stream = streamBedrock(createModel(), baseContext, {});
|
|
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
|
|
|
|
expect(result).not.toBe("timeout");
|
|
if (result === "timeout") {
|
|
throw new Error("Timed out waiting for forwarded error result");
|
|
}
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toContain("bedrock exploded");
|
|
});
|
|
|
|
it("turns idle lazy provider streams into retryable terminal errors", async () => {
|
|
const partialMessage = createAssistantMessage("stop");
|
|
let providerSignal: AbortSignal | undefined;
|
|
const source = {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "start", partial: partialMessage } as const;
|
|
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial: partialMessage } as const;
|
|
const { promise, reject } = Promise.withResolvers<never>();
|
|
if (providerSignal?.aborted) {
|
|
reject(new Error("Request was aborted"));
|
|
}
|
|
providerSignal?.addEventListener("abort", () => reject(new Error("Request was aborted")), {
|
|
once: true,
|
|
});
|
|
await promise;
|
|
},
|
|
} as unknown as AssistantMessageEventStream;
|
|
|
|
setBedrockProviderModule({
|
|
streamBedrock: (_model, _context, options) => {
|
|
providerSignal = options.signal;
|
|
return source;
|
|
},
|
|
});
|
|
|
|
const stream = streamBedrock(createModel(), baseContext, { streamIdleTimeoutMs: 10 });
|
|
const result = await Promise.race([stream.result(), Bun.sleep(500).then(() => "timeout" as const)]);
|
|
|
|
expect(result).not.toBe("timeout");
|
|
if (result === "timeout") {
|
|
throw new Error("Timed out waiting for forwarded stream stall result");
|
|
}
|
|
expect(providerSignal?.aborted).toBe(true);
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
|
|
// The watchdog's StreamTimeoutError classification must survive onto the
|
|
// message so session-level auto-retry can classify it structurally.
|
|
expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true);
|
|
expect(AIError.is(result.errorId, AIError.Flag.Timeout)).toBe(true);
|
|
expect(AIError.retriable(result.errorId)).toBe(true);
|
|
});
|
|
|
|
it("honors model.compat.streamIdleTimeoutMs as the lazy watchdog fallback", async () => {
|
|
const partialMessage = createAssistantMessage("stop");
|
|
let providerSignal: AbortSignal | undefined;
|
|
const source = {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "start", partial: partialMessage } as const;
|
|
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial: partialMessage } as const;
|
|
const { promise, reject } = Promise.withResolvers<never>();
|
|
if (providerSignal?.aborted) {
|
|
reject(new Error("Request was aborted"));
|
|
}
|
|
providerSignal?.addEventListener("abort", () => reject(new Error("Request was aborted")), {
|
|
once: true,
|
|
});
|
|
await promise;
|
|
},
|
|
} as unknown as AssistantMessageEventStream;
|
|
|
|
setBedrockProviderModule({
|
|
streamBedrock: (_model, _context, options) => {
|
|
providerSignal = options.signal;
|
|
return source;
|
|
},
|
|
});
|
|
|
|
// No per-call option: the catalog compat override must reach the lazy
|
|
// watchdog (a stalled Bedrock stream previously waited the generic 300s
|
|
// default because model.compat was ignored on this path).
|
|
const model = createModel({ reasoning: true, compat: { streamIdleTimeoutMs: 20 } });
|
|
expect(model.compat.streamIdleTimeoutMs).toBe(20);
|
|
const stream = streamBedrock(model, baseContext, {});
|
|
// Real-clock race guard (matching this file's other lazy-stream tests):
|
|
// the lazy watchdog runs on the platform clock, so fake timers cannot
|
|
// drive it; the 20ms compat deadline settles the result long before the
|
|
// bound, which exists only to fail fast instead of hanging the test.
|
|
const result = await Promise.race([stream.result(), Bun.sleep(2_000).then(() => "timeout" as const)]);
|
|
|
|
expect(result).not.toBe("timeout");
|
|
if (result === "timeout") {
|
|
throw new Error("Timed out waiting for compat-driven stream stall result");
|
|
}
|
|
expect(providerSignal?.aborted).toBe(true);
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
|
|
});
|
|
|
|
it("preserves caller aborts while forwarding lazy provider streams", async () => {
|
|
const abortController = new AbortController();
|
|
const partialMessage = createAssistantMessage("stop");
|
|
let providerSignal: AbortSignal | undefined;
|
|
const source = {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "start", partial: partialMessage } as const;
|
|
const { promise, reject } = Promise.withResolvers<never>();
|
|
if (providerSignal?.aborted) {
|
|
reject(new Error("Request was aborted"));
|
|
}
|
|
providerSignal?.addEventListener("abort", () => reject(new Error("Request was aborted")), {
|
|
once: true,
|
|
});
|
|
await promise;
|
|
},
|
|
} as unknown as AssistantMessageEventStream;
|
|
|
|
setBedrockProviderModule({
|
|
streamBedrock: (_model, _context, options) => {
|
|
providerSignal = options.signal;
|
|
return source;
|
|
},
|
|
});
|
|
|
|
const stream = streamBedrock(createModel(), baseContext, {
|
|
signal: abortController.signal,
|
|
streamIdleTimeoutMs: 500,
|
|
});
|
|
const iterator = stream[Symbol.asyncIterator]();
|
|
const firstEvent = await iterator.next();
|
|
expect(firstEvent.value?.type).toBe("start");
|
|
|
|
abortController.abort();
|
|
const result = await Promise.race([stream.result(), Bun.sleep(500).then(() => "timeout" as const)]);
|
|
|
|
expect(result).not.toBe("timeout");
|
|
if (result === "timeout") {
|
|
throw new Error("Timed out waiting for forwarded caller abort result");
|
|
}
|
|
expect(result.stopReason).toBe("aborted");
|
|
expect(result.errorMessage).toBe("Request was aborted");
|
|
});
|
|
});
|