Files
openclaw/extensions/amazon-bedrock/stream.runtime.lifecycle.test.ts
Onur Solmaz 1319e1e606 fix(providers): report request acceptance consistently (#126028)
* fix(providers): restore provider acceptance lifecycle

* fix(providers): cancel streams on acceptance failure

* test(amazon-bedrock): split stream lifecycle coverage

* refactor(providers): keep acceptance lifecycle private

* refactor(providers): make acceptance observation synchronous

* refactor(providers): narrow private observer safely

* fix(google): keep response hook within retry deadline

* test(ui): tolerate subpixel spacing variance

* test(ui): wait for durable draft before reload
2026-08-21 19:32:07 +03:00

254 lines
8.3 KiB
TypeScript

import {
BedrockRuntimeClient,
ConversationRole,
StopReason as BedrockStopReason,
} from "@aws-sdk/client-bedrock-runtime";
import { withProviderAcceptanceObserver } from "openclaw/plugin-sdk/provider-transport-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamSimpleBedrock } from "./stream.runtime.js";
const model = {
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "amazon.nova-micro-v1:0",
name: "Nova Micro",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} as const;
async function* events(items: unknown[]) {
yield* items;
}
function streamBedrockForTest(options: Parameters<typeof streamSimpleBedrock>[2] = {}) {
return streamSimpleBedrock(
model as never,
{ messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never,
options,
);
}
function expectDestroyedClient(
send: ReturnType<typeof vi.spyOn>,
destroy: ReturnType<typeof vi.spyOn>,
) {
expect(send).toHaveBeenCalledOnce();
expect(destroy).toHaveBeenCalledOnce();
expect(destroy.mock.contexts[0]).toBe(send.mock.contexts[0]);
expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan(
send.mock.invocationCallOrder[0] ?? 0,
);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("Bedrock provider-owned stream lifecycle", () => {
it.each([
{
label: "text",
blocks: [{ contentBlockDelta: { contentBlockIndex: 0, delta: { text: "ready" } } }],
endEvent: "text_end",
stopReason: BedrockStopReason.END_TURN,
},
{
label: "thinking",
blocks: [
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { text: "considered" } },
},
},
],
endEvent: "thinking_end",
stopReason: BedrockStopReason.END_TURN,
},
{
label: "redacted thinking",
blocks: [
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { redactedContent: new Uint8Array([1, 2, 3]) } },
},
},
],
endEvent: "thinking_end",
stopReason: BedrockStopReason.END_TURN,
},
{
label: "tool call",
blocks: [
{
contentBlockStart: {
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "call_lookup", name: "lookup" } },
},
},
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { toolUse: { input: '{"query":"ready"}' } },
},
},
{ contentBlockStop: { contentBlockIndex: 0 } },
],
endEvent: "toolcall_end",
stopReason: BedrockStopReason.TOOL_USE,
},
])("finalizes the active $label block at the provider terminal boundary", async (scenario) => {
vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: events([
{ messageStart: { role: ConversationRole.ASSISTANT } },
...scenario.blocks,
{ messageStop: { stopReason: scenario.stopReason } },
]),
} as never);
const stream = streamSimpleBedrock(model as never, {
messages: [{ role: "user", content: "Continue", timestamp: 0 }],
});
const observed = [];
for await (const event of stream) {
observed.push(event.type);
}
const output = await stream.result();
expect(observed.at(-2)).toBe(scenario.endEvent);
expect(observed.at(-1)).toBe("done");
expect(output.content[0]).not.toHaveProperty("index");
expect(output.content[0]).not.toHaveProperty("partialJson");
if (scenario.label === "redacted thinking") {
expect(output.content[0]).toMatchObject({ redacted: true, thinkingSignature: "AQID" });
}
});
});
describe("Bedrock stream client lifecycle", () => {
it("destroys the client after a successful stream", async () => {
let markStreamBlocked!: () => void;
const streamBlocked = new Promise<void>((resolve) => {
markStreamBlocked = resolve;
});
let releaseStream!: () => void;
const streamReleased = new Promise<void>((resolve) => {
releaseStream = resolve;
});
async function* successfulStream() {
yield { messageStart: { role: ConversationRole.ASSISTANT } };
markStreamBlocked();
await streamReleased;
yield { messageStop: { stopReason: BedrockStopReason.END_TURN } };
}
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200, requestId: "bedrock-request-1" },
stream: successfulStream(),
} as never);
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
const acceptanceObserver = vi.fn();
const onResponse = vi.fn();
const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver);
const resultPromise = streamBedrockForTest(options).result();
await streamBlocked;
expect(destroy).not.toHaveBeenCalled();
releaseStream();
const result = await resultPromise;
expect(result.stopReason).toBe("stop");
expect(acceptanceObserver).toHaveBeenCalledWith({
kind: "http_response",
status: 200,
headers: { "x-amzn-requestid": "bedrock-request-1" },
});
expect(onResponse).toHaveBeenCalledWith(
{ status: 200, headers: { "x-amzn-requestid": "bedrock-request-1" } },
expect.objectContaining({ provider: "amazon-bedrock" }),
);
expectDestroyedClient(send, destroy);
});
it("cancels an unread stream when provider acceptance fails", async () => {
const close = vi.fn(async () => ({ done: true as const, value: undefined }));
const responseIterator = {
next: vi.fn(() => new Promise<IteratorResult<never>>(() => {})),
return: close,
[Symbol.asyncIterator]() {
return this;
},
};
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: responseIterator,
} as never);
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
const hookError = new Error("acceptance observer failed");
const options = withProviderAcceptanceObserver({}, () => {
throw hookError;
});
const result = await streamBedrockForTest(options).result();
expect(result).toMatchObject({
stopReason: "error",
errorMessage: "acceptance observer failed",
});
expect(close).toHaveBeenCalledOnce();
expectDestroyedClient(send, destroy);
});
it("destroys the client after a provider error", async () => {
const send = vi
.spyOn(BedrockRuntimeClient.prototype, "send")
.mockRejectedValue(new Error("synthetic provider failure"));
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
const result = await streamBedrockForTest().result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("synthetic provider failure");
expectDestroyedClient(send, destroy);
});
it("destroys the client when response stream iteration fails", async () => {
async function* failingStream() {
yield { messageStart: { role: ConversationRole.ASSISTANT } };
throw new Error("synthetic iterator failure");
}
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: failingStream(),
} as never);
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
const result = await streamBedrockForTest().result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("synthetic iterator failure");
expectDestroyedClient(send, destroy);
});
it("destroys the client after an aborted request", async () => {
const controller = new AbortController();
controller.abort();
const send = vi
.spyOn(BedrockRuntimeClient.prototype, "send")
.mockRejectedValue(new Error("synthetic abort"));
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
const result = await streamBedrockForTest({ signal: controller.signal }).result();
expect(result.stopReason).toBe("aborted");
expect(result.errorMessage).toBe("synthetic abort");
expectDestroyedClient(send, destroy);
});
});