From 4fff25438cd7e41ad444b88586f8e6c213997dd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 12:56:37 +0100 Subject: [PATCH] fix: propagate stream timeout abort reason (#76633) --- CHANGELOG.md | 1 + .../run/assistant-failover.test.ts | 7 ++- src/utils/fetch-timeout.test.ts | 44 +++++++++++++++++++ src/utils/fetch-timeout.ts | 4 +- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 023291ba749..7f18b458dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts index db7c575ec13..551640c5863 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts @@ -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 diff --git a/src/utils/fetch-timeout.test.ts b/src/utils/fetch-timeout.test.ts index 4a295c44205..9342a9f102d 100644 --- a/src/utils/fetch-timeout.test.ts +++ b/src/utils/fetch-timeout.test.ts @@ -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({ diff --git a/src/utils/fetch-timeout.ts b/src/utils/fetch-timeout.ts index bd1b7c2ffcd..b2425b5a600 100644 --- a/src/utils/fetch-timeout.ts +++ b/src/utils/fetch-timeout.ts @@ -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): {