mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix: propagate stream timeout abort reason (#76633)
This commit is contained in:
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art.
|
||||
- Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol.
|
||||
- Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee.
|
||||
- Agents: keep active streamed provider replies alive by refreshing guarded fetch timeouts on raw body chunks and surface true prompt stream timeouts as explicit errors instead of partial assistant fragments. Fixes #76307. (#76633) Thanks @MkDev11.
|
||||
- Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc.
|
||||
- CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official npm spec during the externalization rollout. Thanks @vincentkoc.
|
||||
- CLI/doctor: match stale bundled-plugin install records by exact parsed package name so doctor does not remove external npm or ClawHub records that only share an OpenClaw package-name prefix.
|
||||
|
||||
@@ -170,10 +170,9 @@ describe("handleAssistantFailover", () => {
|
||||
|
||||
it("leaves plain timeouts on the continue_normal path for the runner's timeout-payload synthesis", async () => {
|
||||
// `run.ts` already emits an explicit timeout payload when
|
||||
// `buildEmbeddedRunPayloads` produces no assistant content (see
|
||||
// the `timedOut && !timedOutDuringCompaction &&
|
||||
// !payloadsWithToolMedia.length` block). Throwing a FailoverError
|
||||
// here would short-circuit that synthesis and break
|
||||
// `buildEmbeddedRunPayloads` produces no assistant content or only a
|
||||
// partial prompt-timeout fragment. Throwing a FailoverError here would
|
||||
// short-circuit that synthesis and break
|
||||
// timeout-compaction retry coverage in
|
||||
// `run.timeout-triggered-compaction.test.ts`. The throw path is
|
||||
// reserved for concrete provider failures that have no other
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Stream } from "openai/streaming";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const warn = vi.hoisted(() => vi.fn());
|
||||
@@ -30,6 +31,10 @@ describe("buildTimeoutAbortSignal", () => {
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(signal?.reason).toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "request timed out",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"fetch timeout reached; aborting operation",
|
||||
@@ -45,6 +50,45 @@ describe("buildTimeoutAbortSignal", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("keeps timeout aborts visible to OpenAI SSE streams instead of cleanly ending", async () => {
|
||||
const { signal, cleanup } = buildTimeoutAbortSignal({
|
||||
timeoutMs: 25,
|
||||
operation: "unit-test",
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"ok": true}\n\n'));
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => controller.error(signal.reason ?? new Error("request timed out")),
|
||||
{ once: true },
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
|
||||
const iterator = Stream.fromSSEResponse(response, new AbortController())[
|
||||
Symbol.asyncIterator
|
||||
]();
|
||||
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { ok: true },
|
||||
});
|
||||
const pending = iterator.next().catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "request timed out",
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("annotates timeout logs when the timer fires late", async () => {
|
||||
vi.setSystemTime(0);
|
||||
const { cleanup } = buildTimeoutAbortSignal({
|
||||
|
||||
@@ -87,7 +87,9 @@ function abortDueToTimeout(
|
||||
...(operation ? { operation } : {}),
|
||||
...(sanitizedUrl ? { url: sanitizedUrl } : {}),
|
||||
});
|
||||
controller.abort();
|
||||
const error = new Error("request timed out");
|
||||
error.name = "TimeoutError";
|
||||
controller.abort(error);
|
||||
}
|
||||
|
||||
export function buildTimeoutAbortSignal(params: TimeoutAbortSignalParams): {
|
||||
|
||||
Reference in New Issue
Block a user